@bitsocial/pubsub-voting 0.4.1 → 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.
@@ -19,7 +19,7 @@ import { z } from "zod";
19
19
  export declare const VoteRangeSchema: z.ZodObject<{
20
20
  min: z.ZodNumber;
21
21
  max: z.ZodNumber;
22
- }, z.core.$strip>;
22
+ }, z.core.$strict>;
23
23
  /**
24
24
  * A reference to a rule by `type`, plus rule-specific options.
25
25
  * Kept loose on purpose: each rule owns and validates its own option schema
@@ -31,60 +31,89 @@ export declare const RuleRefSchema: z.ZodObject<{
31
31
  type: z.ZodString;
32
32
  }, z.core.$loose>;
33
33
  /**
34
- * One chain the contest reads, by ticker. Part of the dependency manifest.
34
+ * The gate: a boolean tree over rule references, deciding who may vote.
35
+ *
36
+ * A leaf wraps one {@link RuleRefSchema}; `all` and `any` compose leaves into conjunction and
37
+ * disjunction, so a contest can require "holds the Pass AND is not on the deny list", or "holds
38
+ * the Pass OR is a moderator", without either rule knowing the other exists. Composition is the
39
+ * document's business, never a rule's: a rule still answers exactly one question about one wallet
40
+ * (see rules/types.ts), and `rules/gate.ts` folds the answers.
35
41
  *
36
- * Only the `chainId` is here it is consensus-critical (bound into every EIP-712 ballot
37
- * domain and defining which chain the rules read). RPC endpoints are deliberately NOT part
38
- * of the criteria: which gateway a client trusts is client-local transport configuration
39
- * (`PubsubVoterOptions.chains` maps ticker/chainId to a client), and two honest verifiers
40
- * reading the same pinned block through different gateways compute identical results. Keeping
41
- * URLs out means an operator can swap a dead RPC provider without changing the document's
42
- * bytes — i.e. without forking the topic and orphaning the contest's votes.
42
+ * The leaf is WRAPPED (`{ rule: { type, ... } }`) rather than bare because {@link RuleRefSchema}
43
+ * is loose by design a custom rule may carry an option named `all` or `any`, which would make a
44
+ * bare leaf structurally ambiguous with a branch exactly when someone writes such a rule.
43
45
  *
44
- * Strict on purpose: the topic is derived from the PARSED document, so an unknown key must
45
- * fail loudly here a plain (stripping) object would silently drop it and derive a different
46
- * topic than the author's raw document implies. This also makes pre-v1 documents that still
47
- * carry `rpcUrls` a loud error instead of a silent re-topic.
46
+ * Canonicity constraints, all of them load-bearing rather than stylistic. The topic is the CID of
47
+ * these bytes, so any document that differs in bytes but not in meaning is a silent topic FORK —
48
+ * two peers running identical rules on two topics, each invisible to the other:
49
+ * - a branch needs at least TWO children, so `{ all: [X] }` cannot exist alongside `X`;
50
+ * - a branch may not REPEAT a child (compared by canonical bytes, so two leaves of one rule type
51
+ * on different options stay distinct requirements) — a repeat among siblings says nothing the
52
+ * shorter tree does not. Across BRANCHES a rule may repeat, deliberately: that is how a gate
53
+ * expresses a requirement no repetition-free tree can ("any two of these three" is
54
+ * `{ any: [{ all: [A, B] }, { all: [A, C] }, { all: [B, C] }] }`). The price is that some
55
+ * redundant spellings survive — `{ all: [{ any: [A, B] }, A] }` is `A` by absorption — so a
56
+ * leaf's identity is NOT unique within a gate, and its position is what identifies it
57
+ * (`EligibilityCheck.leaf`);
58
+ * - a branch may not nest a branch of its OWN kind: `{ all: [{ all: [A, B] }, C] }` admits,
59
+ * scores, blames and penalizes exactly as `{ all: [A, B, C] }` does, since min and `some` are
60
+ * associative, so the nesting carries no meaning and only new bytes;
61
+ * - depth is capped at {@link MAX_GATE_DEPTH} and leaves at {@link MAX_GATE_LEAVES}, because a
62
+ * criteria document is attacker-supplied input that every peer parses and evaluates.
63
+ *
64
+ * Child ORDER is deliberately significant rather than normalized away: it is what the lazy forward
65
+ * gate evaluates in, so it decides which rule's chain read is paid first and the order failures are
66
+ * reported. Two orderings are two documents, and an author picks the one that reads best.
48
67
  */
49
- export declare const ChainConfigSchema: z.ZodObject<{
50
- chainId: z.ZodNumber;
51
- }, z.core.$strict>;
68
+ export interface GateLeaf {
69
+ rule: RuleRef;
70
+ }
71
+ export interface GateAll {
72
+ all: GateNode[];
73
+ }
74
+ export interface GateAny {
75
+ any: GateNode[];
76
+ }
77
+ export type GateNode = GateLeaf | GateAll | GateAny;
78
+ /** Maximum nesting depth of the gate tree (a leaf alone is depth 1). */
79
+ export declare const MAX_GATE_DEPTH = 4;
80
+ /** Maximum number of rule references in one gate tree. */
81
+ export declare const MAX_GATE_LEAVES = 8;
82
+ export declare const GateSchema: z.ZodPipe<z.ZodUnknown, z.ZodType<GateNode, unknown, z.core.$ZodTypeInternals<GateNode, unknown>>>;
52
83
  /**
53
84
  * The dependency manifest. A client reads this on join and checks that it
54
85
  * implements every named rule; if not, it is too old and must recuse
55
86
  * itself rather than miscount. This is how criteria upgrades fork cleanly.
87
+ *
88
+ * Strict for the same reason the top level is: the topic is derived from the PARSED document, so
89
+ * an unknown key must fail loudly rather than be stripped — a stripping schema would drop it and
90
+ * derive a different topic from the one the author's bytes imply. That is what makes a document
91
+ * still carrying the pre-`bucketChainId` `chains` map (or the even older `rpcUrls`) an error
92
+ * instead of a silent re-topic.
56
93
  */
57
94
  export declare const RequiresSchema: z.ZodObject<{
58
95
  rules: z.ZodArray<z.ZodString>;
59
- chains: z.ZodRecord<z.ZodString, z.ZodObject<{
60
- chainId: z.ZodNumber;
61
- }, z.core.$strict>>;
62
- }, z.core.$strip>;
96
+ }, z.core.$strict>;
63
97
  export declare const CriteriaSchema: z.ZodObject<{
64
98
  name: z.ZodString;
65
99
  contestId: z.ZodString;
66
100
  voteSchema: z.ZodObject<{
67
101
  min: z.ZodNumber;
68
102
  max: z.ZodNumber;
69
- }, z.core.$strip>;
103
+ }, z.core.$strict>;
70
104
  maxVotesPerAddress: z.ZodNumber;
105
+ bucketChainId: z.ZodNumber;
71
106
  blocksPerBucket: z.ZodNumber;
72
107
  voteExpiryBuckets: z.ZodNumber;
73
- rule: z.ZodObject<{
74
- type: z.ZodString;
75
- }, z.core.$loose>;
108
+ gate: z.ZodPipe<z.ZodUnknown, z.ZodType<GateNode, unknown, z.core.$ZodTypeInternals<GateNode, unknown>>>;
76
109
  weight: z.ZodObject<{
77
110
  type: z.ZodString;
78
111
  }, z.core.$loose>;
79
112
  requires: z.ZodObject<{
80
113
  rules: z.ZodArray<z.ZodString>;
81
- chains: z.ZodRecord<z.ZodString, z.ZodObject<{
82
- chainId: z.ZodNumber;
83
- }, z.core.$strict>>;
84
- }, z.core.$strip>;
114
+ }, z.core.$strict>;
85
115
  }, z.core.$strict>;
86
116
  export type VoteRange = z.infer<typeof VoteRangeSchema>;
87
117
  export type RuleRef = z.infer<typeof RuleRefSchema>;
88
- export type ChainConfig = z.infer<typeof ChainConfigSchema>;
89
118
  export type Requires = z.infer<typeof RequiresSchema>;
90
119
  export type Criteria = z.infer<typeof CriteriaSchema>;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ChainTickerSchema } from "./common.js";
2
+ import { encodeCanonical } from "../encoding/canonical.js";
3
3
  /**
4
4
  * The criteria document.
5
5
  *
@@ -17,7 +17,7 @@ import { ChainTickerSchema } from "./common.js";
17
17
  * a non-canonical change silently changes the topic.
18
18
  */
19
19
  /** Inclusive numeric bounds for a single `vote` value. v1 is { min: 1, max: 1 }. */
20
- export const VoteRangeSchema = z.object({
20
+ export const VoteRangeSchema = z.strictObject({
21
21
  min: z.number().int(),
22
22
  max: z.number().int()
23
23
  });
@@ -31,33 +31,108 @@ export const VoteRangeSchema = z.object({
31
31
  export const RuleRefSchema = z.looseObject({
32
32
  type: z.string().min(1)
33
33
  });
34
+ /** Maximum nesting depth of the gate tree (a leaf alone is depth 1). */
35
+ export const MAX_GATE_DEPTH = 4;
36
+ /** Maximum number of rule references in one gate tree. */
37
+ export const MAX_GATE_LEAVES = 8;
38
+ const GateNodeSchema = z.lazy(() => z.union([
39
+ z.strictObject({ rule: RuleRefSchema }),
40
+ z.strictObject({ all: z.array(GateNodeSchema).min(2) }),
41
+ z.strictObject({ any: z.array(GateNodeSchema).min(2) })
42
+ ]));
43
+ /** Depth (a leaf is 1), leaf count, and the redundant spellings, in one walk. */
44
+ function gateShape(node) {
45
+ if ("rule" in node)
46
+ return { depth: 1, leaves: 1, redundant: undefined };
47
+ const kind = "all" in node ? "all" : "any";
48
+ const children = "all" in node ? node.all : node.any;
49
+ let depth = 0;
50
+ let leaves = 0;
51
+ let redundant;
52
+ // Canonical bytes are the identity: two children that encode identically ARE the same
53
+ // requirement, however differently they were written. Siblings only — a rule repeated in
54
+ // another branch is how a gate expresses "any two of these three".
55
+ const seen = new Set();
56
+ for (const child of children) {
57
+ const shape = gateShape(child);
58
+ depth = Math.max(depth, shape.depth);
59
+ leaves += shape.leaves;
60
+ redundant ??= shape.redundant;
61
+ if (kind in child) {
62
+ redundant ??= `a \`${kind}\` nested directly inside an \`${kind}\` says nothing its parent does not; inline its children`;
63
+ }
64
+ const bytes = bytesToHex(encodeCanonical(child));
65
+ if (seen.has(bytes))
66
+ redundant ??= `a \`${kind}\` repeats one of its children; drop the duplicate`;
67
+ seen.add(bytes);
68
+ }
69
+ return { depth: depth + 1, leaves, redundant };
70
+ }
71
+ const bytesToHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
34
72
  /**
35
- * One chain the contest reads, by ticker. Part of the dependency manifest.
36
- *
37
- * Only the `chainId` is here it is consensus-critical (bound into every EIP-712 ballot
38
- * domain and defining which chain the rules read). RPC endpoints are deliberately NOT part
39
- * of the criteria: which gateway a client trusts is client-local transport configuration
40
- * (`PubsubVoterOptions.chains` maps ticker/chainId to a client), and two honest verifiers
41
- * reading the same pinned block through different gateways compute identical results. Keeping
42
- * URLs out means an operator can swap a dead RPC provider without changing the document's
43
- * bytes — i.e. without forking the topic and orphaning the contest's votes.
44
- *
45
- * Strict on purpose: the topic is derived from the PARSED document, so an unknown key must
46
- * fail loudly here — a plain (stripping) object would silently drop it and derive a different
47
- * topic than the author's raw document implies. This also makes pre-v1 documents that still
48
- * carry `rpcUrls` a loud error instead of a silent re-topic.
73
+ * Structural bounds, checked on the RAW value before the recursive schema ever descends into it.
74
+ * {@link GateNodeSchema} is `z.lazy` and {@link gateShape} recurses, so a pathological document
75
+ * overflows the stack long before any cap can fire and a `RangeError` escaping `safeParse`
76
+ * breaks the one guarantee that call makes. This walk is iterative and stops at the first node
77
+ * past a bound; anything within them it passes through untouched, so the real schema still
78
+ * produces the precise error for an ordinary authoring mistake.
49
79
  */
50
- export const ChainConfigSchema = z.strictObject({
51
- chainId: z.number().int().positive()
80
+ const MAX_GATE_NODES = MAX_GATE_LEAVES * 2;
81
+ function checkGateBounds(raw, ctx) {
82
+ const stack = [{ node: raw, depth: 1 }];
83
+ let nodes = 0;
84
+ while (stack.length > 0) {
85
+ const { node, depth } = stack.pop();
86
+ if (depth > MAX_GATE_DEPTH) {
87
+ ctx.addIssue({ code: "custom", message: `gate tree is more than ${MAX_GATE_DEPTH} levels deep` });
88
+ return;
89
+ }
90
+ nodes += 1;
91
+ if (nodes > MAX_GATE_NODES) {
92
+ ctx.addIssue({ code: "custom", message: `gate tree has more than ${MAX_GATE_NODES} nodes` });
93
+ return;
94
+ }
95
+ if (typeof node !== "object" || node === null)
96
+ continue; // not a node; the schema says so
97
+ const branch = node;
98
+ const children = Array.isArray(branch.all) ? branch.all : branch.any;
99
+ if (!Array.isArray(children))
100
+ continue;
101
+ for (const child of children)
102
+ stack.push({ node: child, depth: depth + 1 });
103
+ }
104
+ }
105
+ export const GateSchema = z
106
+ .unknown()
107
+ .superRefine(checkGateBounds)
108
+ .pipe(GateNodeSchema)
109
+ .superRefine((node, ctx) => {
110
+ const { depth, leaves, redundant } = gateShape(node);
111
+ if (depth > MAX_GATE_DEPTH) {
112
+ ctx.addIssue({ code: "custom", message: `gate tree is ${depth} levels deep; the maximum is ${MAX_GATE_DEPTH}` });
113
+ }
114
+ if (leaves > MAX_GATE_LEAVES) {
115
+ ctx.addIssue({ code: "custom", message: `gate tree names ${leaves} rules; the maximum is ${MAX_GATE_LEAVES}` });
116
+ }
117
+ // Every redundant spelling is a topic fork waiting to happen: it means the same thing as a
118
+ // shorter tree while encoding to different bytes, so two authors expressing one contest can
119
+ // land on two topics. Same reason a branch may not have a single child.
120
+ if (redundant !== undefined)
121
+ ctx.addIssue({ code: "custom", message: `gate tree has a redundant spelling: ${redundant}` });
52
122
  });
53
123
  /**
54
124
  * The dependency manifest. A client reads this on join and checks that it
55
125
  * implements every named rule; if not, it is too old and must recuse
56
126
  * itself rather than miscount. This is how criteria upgrades fork cleanly.
127
+ *
128
+ * Strict for the same reason the top level is: the topic is derived from the PARSED document, so
129
+ * an unknown key must fail loudly rather than be stripped — a stripping schema would drop it and
130
+ * derive a different topic from the one the author's bytes imply. That is what makes a document
131
+ * still carrying the pre-`bucketChainId` `chains` map (or the even older `rpcUrls`) an error
132
+ * instead of a silent re-topic.
57
133
  */
58
- export const RequiresSchema = z.object({
59
- rules: z.array(z.string().min(1)).nonempty(),
60
- chains: z.record(ChainTickerSchema, ChainConfigSchema)
134
+ export const RequiresSchema = z.strictObject({
135
+ rules: z.array(z.string().min(1)).nonempty()
61
136
  });
62
137
  export const CriteriaSchema = z
63
138
  .object({
@@ -76,12 +151,37 @@ export const CriteriaSchema = z
76
151
  * always allowed as withdrawal/abstention regardless of this cap.
77
152
  */
78
153
  maxVotesPerAddress: z.number().int().positive(),
154
+ /**
155
+ * The chain whose blocks this contest counts in, by numeric chain id.
156
+ *
157
+ * The contest has exactly ONE clock, and this names it: `blocksPerBucket` and
158
+ * `voteExpiryBuckets` are measured in its blocks, a ballot's `blockNumber` and the
159
+ * `sampleBlock` every rule is handed are numbers on it, the tie-break seed is the hash of
160
+ * its bucket boundary block, and its id is bound into every EIP-712 ballot domain.
161
+ *
162
+ * It is a chain ID rather than a ticker because that is the identity the signature domain
163
+ * already carries — a ticker is a label local to a document, and two documents spelling
164
+ * one chain differently would be two topics for one contest. Rules do not name a chain at
165
+ * all: they read this one. Gating across several chains is future work, and needs an
166
+ * answer for what block a rule on a SECOND chain is handed before it can ship — see
167
+ * DESIGN.md "Open questions".
168
+ *
169
+ * RPC endpoints are deliberately not part of the criteria: which gateway a client trusts
170
+ * is client-local configuration (`PubsubVoterOptions.chains` maps this id to a client),
171
+ * and two honest verifiers reading the same pinned block through different gateways
172
+ * compute identical results. That keeps an operator's dead-RPC swap from forking the
173
+ * topic and orphaning the contest's votes.
174
+ */
175
+ bucketChainId: z.number().int().positive(),
79
176
  /** Block bucket size; all verifiers price the same block per bucket. */
80
177
  blocksPerBucket: z.number().int().positive(),
81
178
  /** How many buckets a bundle stays valid after its blockNumber. */
82
179
  voteExpiryBuckets: z.number().int().positive(),
83
- /** Who may vote (gates a wallet in or out). */
84
- rule: RuleRefSchema,
180
+ /**
181
+ * Who may vote (gates a wallet in or out): one rule, or a boolean tree of them.
182
+ * A single-rule gate is spelled `{ rule: { type, ... } }` — see {@link GateSchema}.
183
+ */
184
+ gate: GateSchema,
85
185
  /** How much an eligible vote counts. */
86
186
  weight: RuleRefSchema,
87
187
  /** Dependency manifest + version negotiation. */
@@ -17,7 +17,7 @@ import type { Vote } from "../schema/votes.js";
17
17
  * - `votes`: each community (`{ name, publicKey }`) + numeric vote.
18
18
  * - `blockNumber`: the LWW key and the bucketized block every verifier reads at.
19
19
  *
20
- * The `domain.chainId` is the gating (`rule`) chain, giving cross-chain/cross-app domain
20
+ * The `domain.chainId` is the contest's `bucketChainId`, giving cross-chain/cross-app domain
21
21
  * separation for free. This module is pure: no key material, no network, no viem import
22
22
  * — it only shapes the object both sides feed to viem.
23
23
  */
@@ -96,7 +96,7 @@ export interface BallotTypedData {
96
96
  export declare function ballotTypedData(args: {
97
97
  /** The criteria CID's raw binary bytes (`(await criteriaCid(criteria)).bytes`). */
98
98
  criteriaCid: Uint8Array;
99
- /** The gating (`rule`) chain's numeric chainId (`criteria.requires.chains[chain].chainId`). */
99
+ /** The chain the contest counts in (`criteria.bucketChainId`). */
100
100
  chainId: number;
101
101
  votes: Vote[];
102
102
  blockNumber: number;
@@ -16,7 +16,7 @@
16
16
  * - `votes`: each community (`{ name, publicKey }`) + numeric vote.
17
17
  * - `blockNumber`: the LWW key and the bucketized block every verifier reads at.
18
18
  *
19
- * The `domain.chainId` is the gating (`rule`) chain, giving cross-chain/cross-app domain
19
+ * The `domain.chainId` is the contest's `bucketChainId`, giving cross-chain/cross-app domain
20
20
  * separation for free. This module is pure: no key material, no network, no viem import
21
21
  * — it only shapes the object both sides feed to viem.
22
22
  */
@@ -25,7 +25,8 @@ import type { Tally } from "./types.js";
25
25
  export interface TallyDeps {
26
26
  criteria: Criteria;
27
27
  registry: RuleRegistry;
28
- chainFor: (ticker: string) => ChainClient;
28
+ /** The contest's one chain client — the weight rule reads it too (DESIGN.md "One clock"). */
29
+ chain: ChainClient;
29
30
  bucketMath: BucketMath;
30
31
  /**
31
32
  * The CRDT's current bundles (one per wallet, LWW-resolved; empty-votes bundles are
@@ -1,6 +1,5 @@
1
1
  import { base58btc } from "multiformats/bases/base58";
2
2
  import { sha256 } from "multiformats/hashes/sha2";
3
- import { tickerForRef } from "../chain/ticker.js";
4
3
  import { makeMemoryRuleCache } from "../rules/cache.js";
5
4
  import { scoreOrZero } from "../rules/result.js";
6
5
  import { UnknownRuleError } from "../errors.js";
@@ -16,24 +15,21 @@ function compareBytes(x, y) {
16
15
  return x.length - y.length;
17
16
  }
18
17
  export function makeTally(deps) {
19
- const { criteria, registry, chainFor, bucketMath, current, bucketBlockHash } = deps;
20
- const readHead = deps.readHead ?? (async ({ chain }) => ({ block: Number(await chain.getBlockNumber()) }));
21
- // Resolve the weight rule, its options, and its chain once (see verify/bundle.ts).
18
+ const { criteria, registry, chain, bucketMath, current, bucketBlockHash } = deps;
19
+ const readHead = deps.readHead ?? (async ({ chain: client }) => ({ block: Number(await client.getBlockNumber()) }));
20
+ // Resolve the weight rule and its options once (see verify/bundle.ts).
22
21
  const weight = registry[criteria.weight.type];
23
22
  if (!weight)
24
23
  throw new UnknownRuleError("weight", criteria.weight.type);
25
24
  const weightOptions = weight.optionsSchema.parse(criteria.weight);
26
- const weightChain = chainFor(tickerForRef(criteria, criteria.weight, weightOptions));
27
- // The weight rule picks its own block exactly as the gate rule does (see rules/types.ts):
28
- // it is handed the bundle's pinned sample block and this verifier's head, and reads whichever
29
- // it needs. The tally never asks what kind of rule it is holding.
30
- //
31
- // `deps.ruleCache` MUST be namespaced by the weight rule's OWN chain, not the gating chain —
32
- // `weightChain` here resolves through `criteria.weight`'s ticker, which may name a different
33
- // entry of `requires.chains` (the voter derives it that way; see client/voter.ts).
25
+ // The weight rule picks its own block exactly as a gate rule does (see rules/types.ts): it is
26
+ // handed the bundle's pinned sample block and this verifier's head, and reads whichever it
27
+ // needs. The tally never asks what kind of rule it is holding. It reads the contest's one
28
+ // chain a weight rule on a second chain is the same open question as a gate leaf on one
29
+ // (DESIGN.md "Open questions").
34
30
  const weightCtx = {
35
- chain: weightChain,
36
- head: () => readHead({ chain: weightChain }),
31
+ chain,
32
+ head: () => readHead({ chain }),
37
33
  cache: deps.ruleCache ?? makeMemoryRuleCache()
38
34
  };
39
35
  const weightFor = async (wallet, blockNumber) => {
@@ -25,7 +25,7 @@ export interface CommunityTally {
25
25
  /** Summed weight of upvotes counted so far, in rule score units (`bigint`). */
26
26
  weight: bigint;
27
27
  /**
28
- * True once every bundle contributing to this row has had its gate `rule` confirmed `> 0n`
28
+ * True once every bundle contributing to this row has had its gate confirmed
29
29
  * by an on-chain read at its bucket block. `false` means at least one contribution is still
30
30
  * awaiting its background gate read — never that one failed (a failed gate evicts the
31
31
  * bundle and recounts the row).
@@ -37,7 +37,7 @@ const CURRENT_BUCKET = 0;
37
37
  const BLOCKS_PER_BUCKET = 43_200;
38
38
  const VOTE_EXPIRY_BUCKETS = 30;
39
39
  /** A permissive default verifier; individual tests swap it via {@link VoteNode.setVerifier}. */
40
- const okVerifier = () => ({ valid: true, ruleScore: 1n, resolvedNames: {} });
40
+ const okVerifier = () => ({ valid: true, resolvedNames: {} });
41
41
  /**
42
42
  * One real loopback libp2p + Helia node carrying gossipsub (topic-scoped score params) and
43
43
  * `@libp2p/fetch` — the raw host node with nothing else wired. {@link makeVoteNode} builds the
@@ -118,8 +118,8 @@ export async function makeVoteNode(topic, options = {}) {
118
118
  verify: (bundle) => verifyImpl(bundle),
119
119
  verifyOffline: (bundle) => verifyImpl(bundle),
120
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 })
121
+ // calls checkGates, so it admits rather than pretending to model a gate.
122
+ checkGates: async () => ({ kind: "leaf", leaf: 0, satisfied: true, score: 1n, penalize: false })
123
123
  };
124
124
  const admit = async ({ cid, bytes }) => {
125
125
  await blockstore.put(cid, bytes);
@@ -3,7 +3,7 @@ import type { VotesBundle } from "../schema/votes.js";
3
3
  import type { Criteria } from "../schema/criteria.js";
4
4
  import type { RuleRegistry } from "../rules/types.js";
5
5
  import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
6
- import { type RuleCache } from "../rules/cache.js";
6
+ import type { RuleCache } from "../rules/cache.js";
7
7
  import { type NameResolutionCache } from "./name-resolution-cache.js";
8
8
  import type { VerdictCache } from "./cache.js";
9
9
  import type { VerifyFail } from "./types.js";
@@ -52,18 +52,20 @@ export interface PendingBundle {
52
52
  export interface BackgroundVerifierDeps {
53
53
  criteria: Criteria;
54
54
  registry: RuleRegistry;
55
- chainFor: (ticker: string) => ChainClient;
55
+ /** The contest's one chain client — every rule reads it (DESIGN.md "One clock"). */
56
+ chain: ChainClient;
56
57
  bucketMath: BucketMath;
57
58
  nameResolvers: NameResolver[];
58
59
  /**
59
- * The gate rule's memo, handed to it as `ctx.cache` (rules/cache.ts). Shared with the inline
60
- * forward-gate verifier, so neither re-reads what the other settled.
60
+ * One memo per gate leaf, in `gateLeaves` order, handed to each rule as `ctx.cache`
61
+ * (rules/cache.ts). Shared with the inline forward-gate verifier, so neither re-reads what
62
+ * the other settled.
61
63
  */
62
- ruleCache?: RuleCache;
64
+ ruleCaches?: readonly RuleCache[];
63
65
  /**
64
66
  * This verifier's current head, handed to the rule as `ctx.head`. Resolved by the rule at
65
67
  * most once per batch, so a round stays batchable. Never called by a rule that scores pinned
66
- * historical state. Defaults to the rule chain's own `getBlockNumber()`; the voter injects
68
+ * historical state. Defaults to the contest chain's own `getBlockNumber()`; the voter injects
67
69
  * its coalesced reader.
68
70
  */
69
71
  readHead?: (args: {
@@ -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
  });