@bitsocial/pubsub-voting 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@ import { z } from "zod";
5
5
  * There is no pkc-js author in this library: a vote is signed directly by the
6
6
  * gating-chain wallet that holds the Pass/ERC-20 (see DESIGN.md "Identity: the
7
7
  * voting wallet, nothing else"). What survives from the old author/wallet schema is
8
- * two small, identity-agnostic primitives reused across the wire:
9
- * - `ChainTickerSchema`: the chain identifier ("base", "eth", ...) used by criteria
10
- * and chain-reading rules.
8
+ * one small, identity-agnostic primitive reused across the wire:
11
9
  * - `SignatureSchema`: the detached `{ signature, type }` shape (kept from pkc-js),
12
10
  * here carrying the wallet's EIP-712 ballot signature.
11
+ *
12
+ * Chains are named by numeric `chainId` (`criteria.bucketChainId`), never by ticker: a ticker is
13
+ * a label local to a document, and two documents spelling one chain differently would be two
14
+ * topics for one contest.
13
15
  */
14
- /** Chain ticker is unrestricted ("base", "eth", ...), matching pkc-js. */
15
- export declare const ChainTickerSchema: z.ZodString;
16
16
  /**
17
17
  * A detached signature with an explicit scheme tag. For a Votes bundle the `type` is
18
18
  * the EIP-712 scheme ({@link EIP712_SIGNATURE_TYPE}) and `signature` is the hex
@@ -5,14 +5,14 @@ import { z } from "zod";
5
5
  * There is no pkc-js author in this library: a vote is signed directly by the
6
6
  * gating-chain wallet that holds the Pass/ERC-20 (see DESIGN.md "Identity: the
7
7
  * voting wallet, nothing else"). What survives from the old author/wallet schema is
8
- * two small, identity-agnostic primitives reused across the wire:
9
- * - `ChainTickerSchema`: the chain identifier ("base", "eth", ...) used by criteria
10
- * and chain-reading rules.
8
+ * one small, identity-agnostic primitive reused across the wire:
11
9
  * - `SignatureSchema`: the detached `{ signature, type }` shape (kept from pkc-js),
12
10
  * here carrying the wallet's EIP-712 ballot signature.
11
+ *
12
+ * Chains are named by numeric `chainId` (`criteria.bucketChainId`), never by ticker: a ticker is
13
+ * a label local to a document, and two documents spelling one chain differently would be two
14
+ * topics for one contest.
13
15
  */
14
- /** Chain ticker is unrestricted ("base", "eth", ...), matching pkc-js. */
15
- export const ChainTickerSchema = z.string().min(1);
16
16
  /**
17
17
  * A detached signature with an explicit scheme tag. For a Votes bundle the `type` is
18
18
  * the EIP-712 scheme ({@link EIP712_SIGNATURE_TYPE}) and `signature` is the hex
@@ -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).
@@ -95,6 +95,14 @@ export interface RootChaserDeps {
95
95
  deferVerify: (entries: PendingBundle[]) => void;
96
96
  /** Called once per chase that admitted at least one bundle (drives tally updates). */
97
97
  onMerged?: () => void;
98
+ /**
99
+ * Called once per successfully decoded checkpoint with EVERY bundle CID it referenced —
100
+ * including the ones this chase skipped because we already hold them. That inclusion is the
101
+ * point: a publisher's own bundle is by definition already held, so it never reaches
102
+ * {@link admit}, and "which peers are serving my vote back to me" would be unanswerable from
103
+ * the admit path alone. Reports what the checkpoint CONTAINED, not what was new.
104
+ */
105
+ onCheckpointContents?: (root: CID, cids: CID[]) => void;
98
106
  /** Concurrency limiter shared across chases (a root spray queues, never floods). */
99
107
  limit: <T>(fn: () => Promise<T>) => Promise<T>;
100
108
  /** Per-root deadline (ms); on expiry the abort signal fires and the chase yields nothing. */
@@ -41,7 +41,7 @@ export function toChaseSession(session) {
41
41
  };
42
42
  }
43
43
  export function makeRootChaser(deps) {
44
- const { getBlock, openSession, verifyOffline, cache, isEvaluableNow, hasBundle, admit, deferVerify, onMerged, limit, timeoutMs } = deps;
44
+ const { getBlock, openSession, verifyOffline, cache, isEvaluableNow, hasBundle, admit, deferVerify, onMerged, onCheckpointContents, limit, timeoutMs } = deps;
45
45
  const inFlight = new Map();
46
46
  function addProviders(flight, providers) {
47
47
  for (const peer of providers) {
@@ -112,6 +112,7 @@ export function makeRootChaser(deps) {
112
112
  return; // deadline hit — the hint contributed nothing
113
113
  let merged = false;
114
114
  const pending = [];
115
+ const contained = [];
115
116
  for (const bundle of winners) {
116
117
  if (controller.signal.aborted)
117
118
  break;
@@ -119,6 +120,7 @@ export function makeRootChaser(deps) {
119
120
  // canonical), so the CID matches the advertiser's block and dedups everywhere.
120
121
  const bytes = encodeBundle(bundle);
121
122
  const cid = await bundleCidForBytes(bytes);
123
+ contained.push(cid); // recorded BEFORE the skip below — see onCheckpointContents
122
124
  if (await hasBundle(cid))
123
125
  continue; // already held — nothing to verify
124
126
  const cached = cache.get(cid);
@@ -148,6 +150,8 @@ export function makeRootChaser(deps) {
148
150
  // and batches the gate reads (see verify/background.ts).
149
151
  if (pending.length > 0)
150
152
  deferVerify(pending);
153
+ if (contained.length > 0)
154
+ onCheckpointContents?.(root, contained);
151
155
  if (merged)
152
156
  onMerged?.();
153
157
  }
@@ -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: {