@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.
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ export * from "./rules/erc721-min-balance.js";
20
20
  export * from "./rules/constant.js";
21
21
  export * from "./rules/registry.js";
22
22
  export { makeMemoryRuleCache, makePersistentRuleCache, type RuleCache } from "./rules/cache.js";
23
+ export { gateLeaves } from "./rules/gate.js";
24
+ export type { GateResult, GateLeafResult } from "./rules/gate.js";
23
25
  export * from "./encoding/canonical.js";
24
26
  export * from "./topic.js";
25
27
  export * from "./errors.js";
package/dist/index.js CHANGED
@@ -38,6 +38,10 @@ export * from "./rules/registry.js";
38
38
  // owns the store, the bound and the purge. A custom chain-reading rule MUST memoize through it —
39
39
  // see rules/cache.ts and README "Custom rules".
40
40
  export { makeMemoryRuleCache, makePersistentRuleCache } from "./rules/cache.js";
41
+ // The gate tree. `gateLeaves` enumerates a criteria's rules in document order without evaluating
42
+ // anything (labelling a requirements list, say); the fold itself is the library's, so composition
43
+ // cannot drift between the forward gate, the background verifier and `checkEligibility`.
44
+ export { gateLeaves } from "./rules/gate.js";
41
45
  // Implemented runtime: encoding, topic, errors, identity seam, facade.
42
46
  export * from "./encoding/canonical.js";
43
47
  export * from "./topic.js";
@@ -28,7 +28,6 @@ import type { Rule } from "./types.js";
28
28
  */
29
29
  export declare const Erc20BalanceOptionsSchema: z.ZodObject<{
30
30
  type: z.ZodLiteral<"erc20-balance">;
31
- chain: z.ZodString;
32
31
  contract: z.ZodString;
33
32
  decimals: z.ZodDefault<z.ZodNumber>;
34
33
  min: z.ZodDefault<z.ZodNumber>;
@@ -1,6 +1,5 @@
1
1
  import { erc20Abi, formatUnits, getAddress, parseUnits } from "viem";
2
2
  import { z } from "zod";
3
- import { ChainTickerSchema } from "../schema/common.js";
4
3
  /**
5
4
  * Score by ERC-20 balance (for example BSO). Reserved for the pass + BSO combo path.
6
5
  *
@@ -29,7 +28,6 @@ import { ChainTickerSchema } from "../schema/common.js";
29
28
  */
30
29
  export const Erc20BalanceOptionsSchema = z.object({
31
30
  type: z.literal("erc20-balance"),
32
- chain: ChainTickerSchema,
33
31
  contract: z.string(),
34
32
  decimals: z.number().int().nonnegative().default(18),
35
33
  min: z.number().nonnegative().default(0)
@@ -41,7 +41,6 @@ import type { Rule } from "./types.js";
41
41
  export declare const ERC5192_INTERFACE_ID: "0xb45a3c0e";
42
42
  export declare const Erc5192MinBalanceOptionsSchema: z.ZodObject<{
43
43
  type: z.ZodLiteral<"erc5192-min-balance">;
44
- chain: z.ZodString;
45
44
  contract: z.ZodString;
46
45
  min: z.ZodDefault<z.ZodNumber>;
47
46
  }, z.core.$strip>;
@@ -1,6 +1,5 @@
1
1
  import { BaseError, ContractFunctionRevertedError, ContractFunctionZeroDataError, getAddress } from "viem";
2
2
  import { z } from "zod";
3
- import { ChainTickerSchema } from "../schema/common.js";
4
3
  import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from "./nft-balance.js";
5
4
  /**
6
5
  * Hold at least `min` of a **soulbound** ERC-721 (the 5chan Pass). The v1 gate.
@@ -52,7 +51,6 @@ const erc165Abi = [
52
51
  ];
53
52
  export const Erc5192MinBalanceOptionsSchema = z.object({
54
53
  type: z.literal("erc5192-min-balance"),
55
- chain: ChainTickerSchema,
56
54
  contract: z.string(),
57
55
  min: z.number().int().positive().default(1)
58
56
  });
@@ -22,7 +22,6 @@ import type { Rule } from "./types.js";
22
22
  */
23
23
  export declare const Erc721MinBalanceOptionsSchema: z.ZodObject<{
24
24
  type: z.ZodLiteral<"erc721-min-balance">;
25
- chain: z.ZodString;
26
25
  contract: z.ZodString;
27
26
  min: z.ZodDefault<z.ZodNumber>;
28
27
  }, z.core.$strip>;
@@ -1,6 +1,5 @@
1
1
  import { getAddress } from "viem";
2
2
  import { z } from "zod";
3
- import { ChainTickerSchema } from "../schema/common.js";
4
3
  import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from "./nft-balance.js";
5
4
  /**
6
5
  * Hold at least `min` of a **plain** ERC-721.
@@ -24,7 +23,6 @@ import { balanceOf, balancesOfBatched, canBatch, scoreOf, shortfallError } from
24
23
  */
25
24
  export const Erc721MinBalanceOptionsSchema = z.object({
26
25
  type: z.literal("erc721-min-balance"),
27
- chain: ChainTickerSchema,
28
26
  contract: z.string(),
29
27
  min: z.number().int().positive().default(1)
30
28
  });
@@ -0,0 +1,164 @@
1
+ import type { Criteria, GateNode, RuleRef } from "../schema/criteria.js";
2
+ import type { ChainReadContext, Rule, RuleRegistry, RuleResult } from "./types.js";
3
+ import type { ChainClient } from "../chain/types.js";
4
+ import { type RuleCache } from "./cache.js";
5
+ /**
6
+ * The gate tree: how one wallet's per-rule answers fold into one admission decision.
7
+ *
8
+ * The criteria document composes rule references with `all` / `any` (schema/criteria.ts). A rule
9
+ * still answers exactly one question about one wallet and knows nothing about the others — the
10
+ * composition lives here, and like {@link gateFailure} / {@link scoreOrZero} in result.ts it reads
11
+ * the tree's structure and the results' discriminants, NEVER which rule produced them (AGENTS.md,
12
+ * "What a rule owns, and what the pipeline owns").
13
+ *
14
+ * One evaluator serves both callers, which is the whole point of it living in one file: the inline
15
+ * forward gate evaluates lazily and stops as soon as the root is determined (one wallet, and every
16
+ * leaf costs a chain read), while the background verifier and `checkEligibility` evaluate every
17
+ * leaf — the first because its reads are batched per rule across a whole round (collecting all is
18
+ * CHEAPER there than short-circuiting), the second because listing every failure is the feature.
19
+ */
20
+ /** Leaf refs in depth-first order — the index every per-leaf array in the pipeline is keyed by. */
21
+ export declare function gateLeaves(node: GateNode): RuleRef[];
22
+ /** One gate leaf, resolved against a registry and ready to score wallets. */
23
+ export interface ResolvedGateLeaf {
24
+ /** The criteria's reference, verbatim — the bytes a `ruleId` is derived from. */
25
+ ref: RuleRef;
26
+ rule: Rule;
27
+ /** `ref` parsed through the rule's own schema (defaults applied; never written back). */
28
+ options: unknown;
29
+ /** The rule's whole world: its chain, this verifier's head, its own memo. */
30
+ ctx: ChainReadContext;
31
+ /**
32
+ * The QUESTION this leaf asks, as the canonical bytes of its reference. Two leaves with the
33
+ * same key are one question in two positions — legal, and the only way to write "any two of
34
+ * these three" (schema/criteria.ts) — so they must be evaluated once, not once each.
35
+ */
36
+ key: string;
37
+ }
38
+ /**
39
+ * Resolve every gate leaf once per contest: its rule, its parsed options and the memo it computes
40
+ * through. Shared by the inline verifier and the background verifier so the two cannot resolve a
41
+ * gate differently — the same reason the fold above lives in one file.
42
+ *
43
+ * There is one `chain` because a contest has one clock (`criteria.bucketChainId`): every rule is
44
+ * handed block numbers counted in it, so a rule reading a second chain would be answering about
45
+ * the wrong history. See DESIGN.md "One clock".
46
+ *
47
+ * `caches` is per leaf in {@link gateLeaves} order (the voter's persistent, per-rule namespaces);
48
+ * omitted, each leaf gets a private in-memory memo, which is what unit tests want.
49
+ */
50
+ export declare function resolveGate(args: {
51
+ criteria: Criteria;
52
+ registry: RuleRegistry;
53
+ chain: ChainClient;
54
+ readHead: (args: {
55
+ chain: ChainClient;
56
+ }) => Promise<{
57
+ block: number;
58
+ }>;
59
+ caches?: readonly RuleCache[] | undefined;
60
+ }): ResolvedGateLeaf[];
61
+ /**
62
+ * Which leaves ask the same question, computed once per contest.
63
+ *
64
+ * `representatives` lists one leaf index per DISTINCT question, and `ofLeaf[i]` is the position in
65
+ * that list which leaf `i` maps to. Callers evaluate the representatives and fan the answers back
66
+ * out, so a rule named twice in one gate costs one evaluation and one batched chain read — not
67
+ * one per position. Without this the duplicate is not merely wasted CPU: the two positions race,
68
+ * so both miss the rule's memo before either writes it.
69
+ */
70
+ export declare function dedupeLeaves(leaves: readonly ResolvedGateLeaf[]): {
71
+ representatives: number[];
72
+ ofLeaf: number[];
73
+ };
74
+ /** The tree with its leaves numbered, so evaluation order cannot shift what a leaf index means. */
75
+ export type IndexedGate = {
76
+ kind: "leaf";
77
+ leaf: number;
78
+ } | {
79
+ kind: "all" | "any";
80
+ children: IndexedGate[];
81
+ };
82
+ export declare function indexGate(node: GateNode, next?: {
83
+ leaf: number;
84
+ }): IndexedGate;
85
+ /** One leaf's verdict. `satisfied: undefined` means the evaluation short-circuited past it. */
86
+ export interface GateLeafResult {
87
+ kind: "leaf";
88
+ leaf: number;
89
+ satisfied: boolean | undefined;
90
+ /** `0n` unless the leaf passed. */
91
+ score: bigint;
92
+ /** The rule's own voter-facing sentence; present only on a failure. */
93
+ error?: string;
94
+ /** Whether THIS leaf's failure may be blamed on the sender (rules/types.ts). */
95
+ penalize: boolean;
96
+ }
97
+ /**
98
+ * The evaluated tree. `satisfied: undefined` marks a node the evaluation never reached — an `all`
99
+ * short-circuits only on a failing child and an `any` only on a passing one, so everything after
100
+ * the deciding child is skipped and reported as unknown rather than as failed.
101
+ */
102
+ export type GateResult = GateLeafResult | {
103
+ kind: "all" | "any";
104
+ satisfied: boolean | undefined;
105
+ children: GateResult[];
106
+ };
107
+ /** Number every leaf of `node`, then score them through `evaluate` and fold the answers. */
108
+ export declare function evaluateGate(args: {
109
+ node: GateNode;
110
+ /** Score one leaf by its index. Callers memoize; this never calls the same index twice. */
111
+ evaluate: (leaf: number) => Promise<RuleResult>;
112
+ /**
113
+ * Evaluate every leaf even once the outcome is determined. Required whenever the per-leaf
114
+ * results are the output (`checkEligibility`) or already in hand (the batched background
115
+ * verifier); the inline gate leaves it off and pays for only what it needs.
116
+ */
117
+ collectAll?: boolean;
118
+ /**
119
+ * Treat a leaf whose evaluation THREW as unknown (`satisfied: undefined`) instead of letting
120
+ * it reject the whole fold. Off by default, and deliberately so: on a verification path an
121
+ * unreachable chain is an infra failure whose only safe handling is to retry the bundle, and
122
+ * silently folding "could not read" into the tree would let an outage decide a vote.
123
+ *
124
+ * `checkEligibility` turns it on because it answers a person, not the network: a wallet that
125
+ * qualifies through a branch that DID answer must not be told it is ineligible because some
126
+ * other rule's RPC timed out. When the tree cannot be decided without the failed leaf the root
127
+ * comes back unknown, and that caller re-throws the underlying error rather than inventing a
128
+ * verdict.
129
+ */
130
+ tolerateLeafErrors?: boolean;
131
+ /** Called for each leaf that threw under {@link tolerateLeafErrors}, in completion order. */
132
+ onLeafError?: (leaf: number, error: unknown) => void;
133
+ }): Promise<GateResult>;
134
+ /**
135
+ * The gate's score for a wallet it admitted: a leaf's own score, the MINIMUM across an `all` (the
136
+ * binding constraint), the MAXIMUM across the satisfied children of an `any` (the wallet's best
137
+ * qualification). Degenerates to the rule's own score for a single-rule gate. Informational only —
138
+ * a vote's magnitude comes from `criteria.weight`, never from here.
139
+ */
140
+ export declare function gateScore(result: GateResult): bigint;
141
+ /**
142
+ * The failing leaves that EXPLAIN a refusal — what a client shows the voter, and the only honest
143
+ * answer to "which rules failed". It is not every failed leaf: a leaf that failed inside a
144
+ * satisfied `any` cost the wallet nothing, and telling someone to go acquire an asset they do not
145
+ * need is worse than saying nothing.
146
+ */
147
+ export declare function gateBlame(result: GateResult): GateLeafResult[];
148
+ /**
149
+ * May a refusal be blamed on the peer that sent the vote? The recursive generalization of
150
+ * `RuleResult.penalize` (rules/types.ts) — and the reason it cannot be a simple OR:
151
+ *
152
+ * - an `all` fails as soon as ONE child does, so a single attributable failure is enough: that
153
+ * child alone closes the gate identically on every honest verifier.
154
+ * - an `any` fails only when EVERY child does, so it is attributable only if every one of them
155
+ * is. One child whose failure another peer could legitimately disagree about means that peer
156
+ * may be looking at a wallet this gate would admit.
157
+ *
158
+ * A short-circuited `all` can only UNDER-report (it stops at the first failure and never learns
159
+ * whether a later child was attributable), which is the fail-safe direction: the cost is a
160
+ * spammer we merely `ignore`, against wrongly reject-scoring an honest relayer.
161
+ */
162
+ export declare function gatePenalize(result: GateResult): boolean;
163
+ /** The blame set's reasons, as the one sentence `VerifyFail.reason` and `VoteEvictedError` carry. */
164
+ export declare function gateReason(result: GateResult): string;
@@ -0,0 +1,209 @@
1
+ import { encodeCanonical } from "../encoding/canonical.js";
2
+ import { makeMemoryRuleCache } from "./cache.js";
3
+ import { UnknownRuleError } from "../errors.js";
4
+ import { gateFailure, scoreOrZero } from "./result.js";
5
+ /**
6
+ * The gate tree: how one wallet's per-rule answers fold into one admission decision.
7
+ *
8
+ * The criteria document composes rule references with `all` / `any` (schema/criteria.ts). A rule
9
+ * still answers exactly one question about one wallet and knows nothing about the others — the
10
+ * composition lives here, and like {@link gateFailure} / {@link scoreOrZero} in result.ts it reads
11
+ * the tree's structure and the results' discriminants, NEVER which rule produced them (AGENTS.md,
12
+ * "What a rule owns, and what the pipeline owns").
13
+ *
14
+ * One evaluator serves both callers, which is the whole point of it living in one file: the inline
15
+ * forward gate evaluates lazily and stops as soon as the root is determined (one wallet, and every
16
+ * leaf costs a chain read), while the background verifier and `checkEligibility` evaluate every
17
+ * leaf — the first because its reads are batched per rule across a whole round (collecting all is
18
+ * CHEAPER there than short-circuiting), the second because listing every failure is the feature.
19
+ */
20
+ /** Leaf refs in depth-first order — the index every per-leaf array in the pipeline is keyed by. */
21
+ export function gateLeaves(node) {
22
+ if ("rule" in node)
23
+ return [node.rule];
24
+ const children = "all" in node ? node.all : node.any;
25
+ return children.flatMap(gateLeaves);
26
+ }
27
+ /**
28
+ * Resolve every gate leaf once per contest: its rule, its parsed options and the memo it computes
29
+ * through. Shared by the inline verifier and the background verifier so the two cannot resolve a
30
+ * gate differently — the same reason the fold above lives in one file.
31
+ *
32
+ * There is one `chain` because a contest has one clock (`criteria.bucketChainId`): every rule is
33
+ * handed block numbers counted in it, so a rule reading a second chain would be answering about
34
+ * the wrong history. See DESIGN.md "One clock".
35
+ *
36
+ * `caches` is per leaf in {@link gateLeaves} order (the voter's persistent, per-rule namespaces);
37
+ * omitted, each leaf gets a private in-memory memo, which is what unit tests want.
38
+ */
39
+ export function resolveGate(args) {
40
+ const { criteria, registry, chain, readHead, caches } = args;
41
+ return gateLeaves(criteria.gate).map((ref, index) => {
42
+ const rule = registry[ref.type];
43
+ if (!rule)
44
+ throw new UnknownRuleError("gate", ref.type);
45
+ const options = rule.optionsSchema.parse(ref);
46
+ return {
47
+ ref,
48
+ rule,
49
+ options,
50
+ ctx: { chain, head: () => readHead({ chain }), cache: caches?.[index] ?? makeMemoryRuleCache() },
51
+ key: Array.from(encodeCanonical(ref), (byte) => byte.toString(16).padStart(2, "0")).join("")
52
+ };
53
+ });
54
+ }
55
+ /**
56
+ * Which leaves ask the same question, computed once per contest.
57
+ *
58
+ * `representatives` lists one leaf index per DISTINCT question, and `ofLeaf[i]` is the position in
59
+ * that list which leaf `i` maps to. Callers evaluate the representatives and fan the answers back
60
+ * out, so a rule named twice in one gate costs one evaluation and one batched chain read — not
61
+ * one per position. Without this the duplicate is not merely wasted CPU: the two positions race,
62
+ * so both miss the rule's memo before either writes it.
63
+ */
64
+ export function dedupeLeaves(leaves) {
65
+ const at = new Map();
66
+ const representatives = [];
67
+ const ofLeaf = leaves.map((leaf, index) => {
68
+ const seen = at.get(leaf.key);
69
+ if (seen !== undefined)
70
+ return seen;
71
+ at.set(leaf.key, representatives.length);
72
+ representatives.push(index);
73
+ return representatives.length - 1;
74
+ });
75
+ return { representatives, ofLeaf };
76
+ }
77
+ export function indexGate(node, next = { leaf: 0 }) {
78
+ if ("rule" in node)
79
+ return { kind: "leaf", leaf: next.leaf++ };
80
+ const kind = "all" in node ? "all" : "any";
81
+ const children = "all" in node ? node.all : node.any;
82
+ return { kind, children: children.map((child) => indexGate(child, next)) };
83
+ }
84
+ const UNEVALUATED = (leaf) => ({ kind: "leaf", leaf, satisfied: undefined, score: 0n, penalize: false });
85
+ /** Number every leaf of `node`, then score them through `evaluate` and fold the answers. */
86
+ export async function evaluateGate(args) {
87
+ const { node, evaluate, collectAll = false, tolerateLeafErrors = false, onLeafError } = args;
88
+ const run = async (indexed) => {
89
+ if (indexed.kind === "leaf") {
90
+ let result;
91
+ try {
92
+ result = await evaluate(indexed.leaf);
93
+ }
94
+ catch (error) {
95
+ if (!tolerateLeafErrors)
96
+ throw error;
97
+ onLeafError?.(indexed.leaf, error);
98
+ return UNEVALUATED(indexed.leaf);
99
+ }
100
+ const failed = gateFailure(result);
101
+ return failed
102
+ ? { kind: "leaf", leaf: indexed.leaf, satisfied: false, score: 0n, error: failed.error, penalize: failed.penalize }
103
+ : { kind: "leaf", leaf: indexed.leaf, satisfied: true, score: scoreOrZero(result), penalize: false };
104
+ }
105
+ // An `all` is determined by its first failing child, an `any` by its first passing one.
106
+ const decidesAt = indexed.kind === "all" ? false : true;
107
+ if (collectAll) {
108
+ const children = await Promise.all(indexed.children.map(run));
109
+ return { kind: indexed.kind, satisfied: foldSatisfied(indexed.kind, children), children };
110
+ }
111
+ const children = [];
112
+ for (const [position, child] of indexed.children.entries()) {
113
+ const result = await run(child);
114
+ children.push(result);
115
+ if (isSatisfied(result) === decidesAt) {
116
+ // Everything after this point cannot change the answer: record the skipped leaves
117
+ // as unevaluated so the tree still mirrors the document's shape.
118
+ for (const rest of indexed.children.slice(position + 1))
119
+ children.push(...skipped(rest));
120
+ return { kind: indexed.kind, satisfied: decidesAt, children };
121
+ }
122
+ }
123
+ // No child decided it, so every one of them settled the other way — unless a read failed
124
+ // under `tolerateLeafErrors`, in which case the branch is honestly unknown.
125
+ return { kind: indexed.kind, satisfied: foldSatisfied(indexed.kind, children), children };
126
+ };
127
+ return run(indexGate(node));
128
+ }
129
+ /** A node the evaluation never reached, shaped like the result it would have produced. */
130
+ function skipped(indexed) {
131
+ if (indexed.kind === "leaf")
132
+ return [UNEVALUATED(indexed.leaf)];
133
+ return [{ kind: indexed.kind, satisfied: undefined, children: indexed.children.flatMap(skipped) }];
134
+ }
135
+ const isSatisfied = (result) => result.satisfied === true;
136
+ /**
137
+ * A branch's verdict from its children's, in three values rather than two — `undefined` is "not
138
+ * known", and only a KNOWN answer may decide. An `all` fails on any known failure and admits only
139
+ * once every child is known to admit; an `any` admits on any known success and refuses only once
140
+ * every alternative is known to have failed. Anything else is unknown, which is what keeps a leaf
141
+ * nobody could read (see `tolerateLeafErrors`) or one the evaluation skipped from being counted as
142
+ * a refusal the wallet is supposed to act on.
143
+ */
144
+ function foldSatisfied(kind, children) {
145
+ const decidesAt = kind === "all" ? false : true;
146
+ if (children.some((child) => child.satisfied === decidesAt))
147
+ return decidesAt;
148
+ return children.every((child) => child.satisfied === !decidesAt) ? !decidesAt : undefined;
149
+ }
150
+ /**
151
+ * The gate's score for a wallet it admitted: a leaf's own score, the MINIMUM across an `all` (the
152
+ * binding constraint), the MAXIMUM across the satisfied children of an `any` (the wallet's best
153
+ * qualification). Degenerates to the rule's own score for a single-rule gate. Informational only —
154
+ * a vote's magnitude comes from `criteria.weight`, never from here.
155
+ */
156
+ export function gateScore(result) {
157
+ if (result.kind === "leaf")
158
+ return result.score;
159
+ const scores = result.children.filter(isSatisfied).map(gateScore);
160
+ if (scores.length === 0)
161
+ return 0n;
162
+ return result.kind === "all"
163
+ ? scores.reduce((min, score) => (score < min ? score : min))
164
+ : scores.reduce((max, score) => (score > max ? score : max));
165
+ }
166
+ /**
167
+ * The failing leaves that EXPLAIN a refusal — what a client shows the voter, and the only honest
168
+ * answer to "which rules failed". It is not every failed leaf: a leaf that failed inside a
169
+ * satisfied `any` cost the wallet nothing, and telling someone to go acquire an asset they do not
170
+ * need is worse than saying nothing.
171
+ */
172
+ export function gateBlame(result) {
173
+ if (result.satisfied !== false)
174
+ return [];
175
+ if (result.kind === "leaf")
176
+ return [result];
177
+ // A failing `all` is explained by whichever children failed; a failing `any` by all of them,
178
+ // since every one of its alternatives is a road the wallet could have taken and did not.
179
+ return result.children.flatMap(gateBlame);
180
+ }
181
+ /**
182
+ * May a refusal be blamed on the peer that sent the vote? The recursive generalization of
183
+ * `RuleResult.penalize` (rules/types.ts) — and the reason it cannot be a simple OR:
184
+ *
185
+ * - an `all` fails as soon as ONE child does, so a single attributable failure is enough: that
186
+ * child alone closes the gate identically on every honest verifier.
187
+ * - an `any` fails only when EVERY child does, so it is attributable only if every one of them
188
+ * is. One child whose failure another peer could legitimately disagree about means that peer
189
+ * may be looking at a wallet this gate would admit.
190
+ *
191
+ * A short-circuited `all` can only UNDER-report (it stops at the first failure and never learns
192
+ * whether a later child was attributable), which is the fail-safe direction: the cost is a
193
+ * spammer we merely `ignore`, against wrongly reject-scoring an honest relayer.
194
+ */
195
+ export function gatePenalize(result) {
196
+ if (result.satisfied !== false)
197
+ return false;
198
+ if (result.kind === "leaf")
199
+ return result.penalize;
200
+ const failing = result.children.filter((child) => child.satisfied === false);
201
+ if (failing.length === 0)
202
+ return false;
203
+ return result.kind === "all" ? failing.some(gatePenalize) : failing.every(gatePenalize);
204
+ }
205
+ /** The blame set's reasons, as the one sentence `VerifyFail.reason` and `VoteEvictedError` carry. */
206
+ export function gateReason(result) {
207
+ const reasons = gateBlame(result).map((leaf) => leaf.error ?? "this wallet does not qualify");
208
+ return [...new Set(reasons)].join("; ");
209
+ }
@@ -53,13 +53,19 @@ export declare const V1_BUILTIN_RULE_TYPES: readonly ["erc5192-min-balance", "co
53
53
  */
54
54
  export declare function resolveRegistry(overrides?: RuleRegistry): RuleRegistry;
55
55
  /**
56
- * Validate a criteria document against a resolved registry: the `rule` and
57
- * `weight` refs must name rules this registry implements, their options must
56
+ * Validate a criteria document against a resolved registry: every leaf of the `gate` tree and the
57
+ * `weight` ref must name rules this registry implements, their options must
58
58
  * parse against the rule's own schema, and every name in `requires.rules`
59
59
  * must be resolvable (so an out-of-date client recuses itself instead of miscounting).
60
60
  *
61
- * Throws `UnknownRuleError` / a zod error on the first failure. This is a check,
62
- * not a transform: it never mutates `criteria`, so the topic-bearing bytes are untouched
63
- * (option defaults applied here do not leak back into the encoded criteria).
61
+ * There is no chain to check: a rule names no chain at all, it reads the one the contest counts in
62
+ * (`criteria.bucketChainId`). That invariant is structural rather than validated the class of
63
+ * "this leaf reads a different chain from the buckets" bug cannot be expressed. Gating across
64
+ * several chains is future work and needs an answer for what block a rule on a SECOND chain is
65
+ * handed; see DESIGN.md "Open questions".
66
+ *
67
+ * Throws `UnknownRuleError` / a zod error on the first failure. This is a check, not a transform:
68
+ * it never mutates `criteria`, so the topic-bearing bytes are untouched (option defaults applied
69
+ * here do not leak back into the encoded criteria).
64
70
  */
65
71
  export declare function validateCriteriaRules(criteria: Criteria, registry: RuleRegistry): void;
@@ -1,3 +1,4 @@
1
+ import { gateLeaves } from "./gate.js";
1
2
  import { UnknownRuleError } from "../errors.js";
2
3
  import { erc5192MinBalance } from "./erc5192-min-balance.js";
3
4
  import { constant } from "./constant.js";
@@ -59,20 +60,28 @@ export function resolveRegistry(overrides) {
59
60
  return { ...builtinRegistry, ...overrides };
60
61
  }
61
62
  /**
62
- * Validate a criteria document against a resolved registry: the `rule` and
63
- * `weight` refs must name rules this registry implements, their options must
63
+ * Validate a criteria document against a resolved registry: every leaf of the `gate` tree and the
64
+ * `weight` ref must name rules this registry implements, their options must
64
65
  * parse against the rule's own schema, and every name in `requires.rules`
65
66
  * must be resolvable (so an out-of-date client recuses itself instead of miscounting).
66
67
  *
67
- * Throws `UnknownRuleError` / a zod error on the first failure. This is a check,
68
- * not a transform: it never mutates `criteria`, so the topic-bearing bytes are untouched
69
- * (option defaults applied here do not leak back into the encoded criteria).
68
+ * There is no chain to check: a rule names no chain at all, it reads the one the contest counts in
69
+ * (`criteria.bucketChainId`). That invariant is structural rather than validated the class of
70
+ * "this leaf reads a different chain from the buckets" bug cannot be expressed. Gating across
71
+ * several chains is future work and needs an answer for what block a rule on a SECOND chain is
72
+ * handed; see DESIGN.md "Open questions".
73
+ *
74
+ * Throws `UnknownRuleError` / a zod error on the first failure. This is a check, not a transform:
75
+ * it never mutates `criteria`, so the topic-bearing bytes are untouched (option defaults applied
76
+ * here do not leak back into the encoded criteria).
70
77
  */
71
78
  export function validateCriteriaRules(criteria, registry) {
72
- const rule = registry[criteria.rule.type];
73
- if (!rule)
74
- throw new UnknownRuleError("rule", criteria.rule.type);
75
- rule.optionsSchema.parse(criteria.rule);
79
+ for (const ref of gateLeaves(criteria.gate)) {
80
+ const rule = registry[ref.type];
81
+ if (!rule)
82
+ throw new UnknownRuleError("gate", ref.type);
83
+ rule.optionsSchema.parse(ref);
84
+ }
76
85
  const weight = registry[criteria.weight.type];
77
86
  if (!weight)
78
87
  throw new UnknownRuleError("weight", criteria.weight.type);
@@ -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