@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/README.md CHANGED
@@ -24,7 +24,7 @@ This library does not start its own node. It consumes the host's running Helia n
24
24
 
25
25
  - **Settings live in the topic.** `topic = "bitsocial-votes/" + CID(dag-cbor(criteria))`. Two peers on the same topic provably ran identical rules, so the network validates itself with no intermediary.
26
26
  - **Votes are a state-based grow-only CRDT.** A signed `Votes` bundle is a standalone dag-cbor block (no parent links); each wallet gossips its own bundle **inline as a live delta**, validated straight from the message bytes — no fetch toward the publisher. State is a last-write-wins set keyed by wallet, so aggregation is a monotonic union: a peer can omit a vote but can never subtract one that an honest peer serves. Cold start and gap-fill exchange a tiny **root record** (libp2p-fetch pull + a slow topic heartbeat) and pull the checkpoint blocks behind it via directed bitswap from its advertisers.
27
- - **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate `rule` (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked, read at the verifier's head so a freshly-acquired Pass votes immediately) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
27
+ - **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier, and the criteria's `gate` composes those rules with `all` / `any` — so "the Pass, or a moderator, and not banned" is a document, not a custom rule. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked, read at the verifier's head so a freshly-acquired Pass votes immediately) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
28
28
 
29
29
  See [DESIGN.md](./DESIGN.md) for the full rationale, including how this resists vote-dropping and how criteria upgrades fork cleanly.
30
30
 
@@ -39,13 +39,24 @@ The library never starts a node and never takes a host SDK (there is no `pkc` ar
39
39
  | Seam | Type | Required | Purpose |
40
40
  |---|---|---|---|
41
41
  | `helia` | `HeliaInstance` | yes | the host's running Helia node; must carry a gossipsub service at `libp2p.services.pubsub` (else `MissingPubsubError`), a `blockstore` (else `MissingBlockstoreError`), and a libp2p fetch service at `libp2p.services.fetch` (else `MissingFetchError`) |
42
- | `chains` | `ChainClientFactory` | yes | resolves each chain a contest's criteria requires (`{ chain, chainId }`) to a viem `PublicClient`; rules read through it for the gate and weight. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that carries a multicall3 deployment in its viem `chain` config and serves **historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head** (the v1 gate reads the head first, but falls back to the block a ballot names — see [Custom rules](#custom-rules)); return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
42
+ | `chains` | `ChainClientFactory` | yes | resolves the chain a contest counts in (`{ chainId }`, from `criteria.bucketChainId`) to a viem `PublicClient`; every gate rule and the weight rule read through it. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that carries a multicall3 deployment in its viem `chain` config and serves **historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head** (the v1 gate reads the head first, but falls back to the block a ballot names — see [Custom rules](#custom-rules)); return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
43
43
  | `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
44
44
  | `nameResolvers` | `NameResolver[]` | no | community-name resolvers (same interface and instances as pkc-js's `nameResolvers`, e.g. `@bitsocial/bso-resolver` for `name.bso`); each vote's `community.name` claim is verified through them — inline at the forward-gate for live votes, in the background verifier for cold-join admits — and a bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted |
45
45
  | `dataPath` | `string \| false` | no | directory for the voter's persistent state (gate-result + name-resolution caches, and each joined contest's **checkpoint snapshot** — its last fully-verified winner-set, reloaded at join so a restart with no other peer online keeps the tally), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/` + `{dataPath}/checkpoints.db`); in the browser the path is ignored and everything lives in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC, and restores each contest's checkpoint before the cold-start pull. A seeder should always set a stable path |
46
46
  | `httpRouterUrls` | `string[]` | no | Delegated Routing V1 router base URLs to **announce provider records to** (one unsigned `PUT /routing/v1/providers` per router; `Keys` batches every joined contest's criteria CID + current checkpoint root + chunk CIDs — hourly, debounced on root changes, and on address changes). **Seeders only**: absent/empty means never announce (the default — plain clients are not dialable), and the browser build never announces regardless. The node must be publicly **reachable** (its listening port open/forwarded/published), but it does not need to know its own public IP: private, loopback, and link-local addrs are filtered client-side, and when nothing survives — the normal zero-config case behind NAT or a Docker bridge, and even on public-IP hosts, since libp2p withholds unconfirmed public addrs pending AutoNAT — the announcer sends the wildcard sentinels (`/ip4/0.0.0.0/...`, `/ip6/::/...`) that the router rewrites to the PUT's observed source IP, exactly as kubo announces work. Configured `addresses.announce` values (concrete public addrs, DNS/AutoTLS, or a kubo-style wildcard) are used as-is. Only a loopback-only node announces nothing. *Querying* needs no URLs here — cold-join discovery uses the injected node's `libp2p.contentRouting`, which the host wires its routers into |
47
47
 
48
- A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names each required chain only by ticker + `chainId`; RPC endpoints stay out of it, so operators can swap gateways without forking the topic.
48
+ A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names its chain only by `bucketChainId`; RPC endpoints stay out of it, so operators can swap gateways without forking the topic.
49
+
50
+ Who may vote is the document's `gate` — one rule, or a boolean tree of them:
51
+
52
+ ```ts
53
+ gate: { rule: { type: "erc5192-min-balance", contract: "0x13d4…91b9", min: 1 } }
54
+ gate: { all: [{ rule: passRule }, { rule: notBannedRule }] } // every rule must admit
55
+ gate: { any: [{ rule: passRule }, { rule: moderatorRule }] } // any one of them admits
56
+ gate: { all: [{ any: [{ rule: passRule }, { rule: moderatorRule }] }, { rule: notBannedRule }] }
57
+ ```
58
+
59
+ Each rule still answers one question about one wallet and knows nothing about the others; the document composes them. Because the topic is the CID of these bytes, the schema rejects the spellings that would mean the same as a shorter tree — a branch needs **at least two** children, may not repeat a child, and may not nest a branch of its own kind (`{ all: [{ all: [A, B] }, C] }` is just `{ all: [A, B, C] }`) — since each would put one contest on two topics. A rule may appear in two different branches, which is how a gate says "any two of these three": `{ any: [{ all: [A, B] }, { all: [A, C] }, { all: [B, C] }] }`. Trees nest at most 4 deep with at most 8 rules in total. No rule names a chain: every one of them reads the chain the contest counts in (`bucketChainId`), which is the chain the block each rule is handed comes from. Child order is significant and is the order the forward gate evaluates in, so put the cheapest or most discriminating rule first.
49
60
 
50
61
  ### Construct a voter
51
62
 
@@ -61,12 +72,12 @@ const viemChainFactory = (): ChainClientFactory => {
61
72
  const clients: Record<number, ReturnType<typeof createPublicClient>> = {
62
73
  [base.id]: createPublicClient({ chain: base, transport: http("https://my-trusted-base-rpc.example") })
63
74
  };
64
- return ({ chainId }) => clients[chainId]; // undefined → recuse contests requiring that chain
75
+ return ({ chainId }) => clients[chainId]; // undefined → recuse contests counting in that chain
65
76
  };
66
77
 
67
78
  const voter = new PubsubVoter({
68
79
  helia, // the host's Helia node; needs a gossipsub service at libp2p.services.pubsub + a blockstore
69
- chains: viemChainFactory(), // ({ chain, chainId }) => viem PublicClient | undefined
80
+ chains: viemChainFactory(), // ({ chainId }) => viem PublicClient | undefined
70
81
  signer: mySigner, // optional; omit → read-only voter
71
82
  nameResolvers: [bsoResolver], // optional; verifies community-name claims (e.g. @bitsocial/bso-resolver)
72
83
  dataPath: "/path/to/data", // optional; persistent state: caches + checkpoint snapshots (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
@@ -93,20 +104,47 @@ await contest.update(); // join the topic, col
93
104
 
94
105
  ### Will this wallet's vote count?
95
106
 
96
- Ask the contest before signing. `checkEligibility` runs the contest's **real gate rule** through
97
- the same chain client, head reader and memo the forward gate uses, so it reads at whatever block
98
- that rule reads at and applies whatever threshold it applies. When it refuses, the `error` is the
99
- rule's own wording — render it verbatim:
107
+ Ask the contest before signing. `checkEligibility` runs the contest's **real gate rules** through
108
+ the same chain clients, head reader and memos the forward gate uses, so each reads at whatever
109
+ block it reads at and applies whatever threshold it applies. When the gate refuses, the wording is
110
+ the rules' own — render it verbatim:
100
111
 
101
112
  ```ts
102
113
  const check = await contest.checkEligibility({ address: wallet });
103
114
  if (check.eligible) {
104
115
  show(`eligible — holds ${check.score}`);
105
116
  } else {
106
- show(check.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
117
+ for (const failure of check.failures) show(failure.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
107
118
  }
108
119
  ```
109
120
 
121
+ With a composite gate you get one entry per rule, so a client can show a checklist rather than a
122
+ single verdict:
123
+
124
+ | field | what it is |
125
+ | --- | --- |
126
+ | `checks` | every rule in the gate, in document order: `{ leaf, ruleId, type, satisfied, score, error? }` |
127
+ | `failures` | the rules whose failure **explains** the refusal — render these |
128
+ | `gate` | the same tree as `criteria.gate`, each node carrying its `satisfied`, for rendering the real requirement |
129
+ | `error` | `failures` joined into one sentence, for a caller that only wants a string |
130
+
131
+ `failures` is deliberately **not** `checks.filter(c => !c.satisfied)`. Under `any`, a wallet that
132
+ qualifies as a moderator also "fails" the Pass rule — telling it to go and buy a Pass would be
133
+ worse than saying nothing.
134
+
135
+ Key rows by **`leaf`** — the rule's position in the gate, and the only field guaranteed unique in
136
+ one result. `type` is not (a gate may name one rule twice on different options) and neither is
137
+ `ruleId` (a gate may name the same rule in two branches). Use `ruleId` to compare *across* results
138
+ instead: equal ids are one question, so a directory of 63 boards gated on one Pass shows the same
139
+ id everywhere, and shares one chain read behind it.
140
+
141
+ A check's `satisfied` is `true`, `false`, or **`undefined`** — the last meaning that rule's chain
142
+ read failed and the gate was decided without it. Render it as unknown, never as a requirement the
143
+ wallet is missing: nothing was learned about them. A wallet admitted by a branch that did answer
144
+ still gets `eligible: true` while an unrelated contract's RPC is down; the call throws only when
145
+ the gate cannot be decided without the rule that failed, because at that point there is no honest
146
+ answer to give.
147
+
110
148
  Do **not** reimplement this by reading balances yourself: which block counts is the rule's
111
149
  business and changes when the rule changes. A client that hard-codes "peers verify at the bucket
112
150
  boundary" keeps telling voters to wait for a window that a head-reading gate no longer imposes.
@@ -225,7 +263,7 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
225
263
 
226
264
  ### Custom rules
227
265
 
228
- The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` — where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **rule** slot treats the score as a gate (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `rule.success ? weight.score : 0n`. A rule that needs a threshold fails below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, wallets, ctx })` batch hook (`wallets` in place of `wallet`, returning `{ results }` — one per input wallet, in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads. The batch is simply everything pending, so its wallets need not share a `sampleBlock`: a rule reading the head scores them all at once, a rule reading pinned blocks groups them itself. (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification).)
266
+ The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` — where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **gate** slot treats the score as admission (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `gate admits ? weight.score : 0n`. A rule never sees the gate it sits in: composition (`all` / `any`), which failures a voter is shown, and whether a refusal may be blamed on the sender are all folded by the library from what each rule returned. A rule that needs a threshold fails below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, wallets, ctx })` batch hook (`wallets` in place of `wallet`, returning `{ results }` — one per input wallet, in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads. The batch is simply everything pending, so its wallets need not share a `sampleBlock`: a rule reading the head scores them all at once, a rule reading pinned blocks groups them itself. (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification).)
229
267
 
230
268
  Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
231
269
 
@@ -298,6 +336,8 @@ type RuleResult =
298
336
 
299
337
  **`penalize`** answers the one thing the library cannot: may the failure be blamed on the sender? `true` (the default) says every honest verifier computes this same failure — true of a read pinned to the block the bundle names — so the forward gate `reject`s the message (penalizing the delivering peer in gossipsub's scoring) and the verdict is cached as terminal. `false` says an honest peer could legitimately disagree, so the bundle is dropped `ignore`-class instead: no penalty, verdict uncached, and the background verifier re-examines it for a grace window before giving up.
300
338
 
339
+ Across a composite gate the library folds those answers, and the fold is not a simple OR. An `all` fails as soon as one child does, so **one** attributable failure makes the whole refusal attributable — that rule alone closes the gate identically everywhere. An `any` fails only when every alternative does, so it is attributable only if **every** one of them is: a single unprovable failure means a peer on a fresher chain may be looking at a wallet this gate would admit, and penalizing it would punish honest relaying.
340
+
301
341
  `erc5192-min-balance` reads the head first — so a freshly-acquired Pass counts immediately — falls back to `wallet.sampleBlock` when the head refuses (ERC-5192 does not forbid burning, and without the fallback a burn would erase votes retroactively for peers that had not verified them yet), memoizes each leg under its own epoch, and returns `penalize: false`, because at validation time it cannot attribute a failure to anyone: the peer that forwarded the vote verified it against *its* head. Every other rule in the tree reads `wallet.sampleBlock` and leaves `penalize` at its default — a transferable or fungible balance can decrease, so reading it at the head would silently invalidate votes already counted. See [DESIGN.md, What a rule owns](./DESIGN.md#what-a-rule-owns-and-what-the-pipeline-owns).
302
342
 
303
343
  ### Weighted voting (deferred)
@@ -18,16 +18,18 @@ import type { PublicClient } from "viem";
18
18
  * balance reads or chainTicker-to-RPC mapping. All of this is net-new here.
19
19
  */
20
20
  export type ChainClient = PublicClient;
21
- /** chainTicker -> client, built from `criteria.requires.chains`. */
22
- export type ChainClients = Record<string, ChainClient>;
23
21
  /**
24
- * Factory the host provides: resolve a chain named by the criteria (`requires.chains`,
25
- * ticker + chainId) to a viem `PublicClient`. The RPC endpoint is the HOST's setting —
26
- * deliberately not part of the criteria document (see schema/criteria.ts,
27
- * `ChainConfigSchema`) so this factory is where ticker/chainId meets the gateways this
28
- * client trusts (typically `createPublicClient({ chain, transport: http(myRpcUrl) })`).
22
+ * Factory the host provides: resolve the chain a contest counts in (`criteria.bucketChainId`)
23
+ * to a viem `PublicClient`. The RPC endpoint is the HOST's setting — deliberately not part of
24
+ * the criteria document (see schema/criteria.ts, `bucketChainId`) — so this factory is where a
25
+ * chain id meets the gateways this client trusts (typically
26
+ * `createPublicClient({ chain, transport: http(myRpcUrl) })`).
29
27
  *
30
- * Return `undefined` (or throw) when no RPC is configured for the named chain: the voter
28
+ * Keyed by chain ID alone, because that is the identity the criteria carries and the EIP-712
29
+ * ballot domain is signed over. A contest reads exactly one chain: every gate rule and the
30
+ * weight rule are handed this client (see DESIGN.md "One clock").
31
+ *
32
+ * Return `undefined` (or throw) when no RPC is configured for that chain: the voter
31
33
  * then throws `MissingChainClientError` at the create seam (`createContest` /
32
34
  * `createContestVote`) — this client must recuse the contest rather than miscount.
33
35
  *
@@ -39,7 +41,6 @@ export type ChainClients = Record<string, ChainClient>;
39
41
  * to bucket sample blocks) and carries a multicall3 deployment in its viem `chain` config.
40
42
  */
41
43
  export type ChainClientFactory = (args: {
42
- chain: string;
43
44
  chainId: number;
44
45
  }) => ChainClient | undefined;
45
46
  /**
@@ -64,19 +64,73 @@ export interface PublishOutcome {
64
64
  */
65
65
  readonly recipientCount: number;
66
66
  }
67
- /** One contest's reactive read view: subscribe, keep the tally in sync, read it. */
68
67
  /**
69
- * What {@link Contest.checkEligibility} found. Shaped like {@link RuleResult} on purpose — it is
70
- * that result, surfacedso the failing branch always carries a reason a client can display.
68
+ * One rule's answer about one wallet, as {@link Contest.checkEligibility} reports it.
69
+ * Shaped like {@link RuleResult} on purpose it is that result, surfaced so a failure always
70
+ * carries a sentence a client can display.
71
+ */
72
+ export interface EligibilityCheck {
73
+ /**
74
+ * This leaf's position in the gate, depth-first in document order — the render key, and the
75
+ * only field guaranteed unique within one result. `type` is not (a gate may name one rule
76
+ * twice on different options) and neither is `ruleId` (a gate may name the SAME rule in two
77
+ * branches, which is how "any two of these three" is written).
78
+ */
79
+ readonly leaf: number;
80
+ /**
81
+ * Stable identity of this rule INSTANCE — the hash of its canonical criteria reference plus
82
+ * the id of the chain it reads, which is also the namespace of its memo. Equal ids mean one
83
+ * question, so they are what to compare ACROSS results: two contests of a 63-board directory
84
+ * gated on one Pass share this id, and share every chain read behind it. Within one gate two
85
+ * leaves may share it; use {@link leaf} to tell them apart.
86
+ */
87
+ readonly ruleId: string;
88
+ /** The rule `type` this leaf names, for grouping and labelling. */
89
+ readonly type: string;
90
+ /**
91
+ * `undefined` when this rule has no answer: its chain read failed and the gate was decided
92
+ * without it. Render it as unknown, never as a failure — nothing was learned about the wallet.
93
+ */
94
+ readonly satisfied: boolean | undefined;
95
+ /** This rule's score for the wallet; `0n` unless it passed. */
96
+ readonly score: bigint;
97
+ /** The rule's own explanation, written for the voter. Render it verbatim. */
98
+ readonly error?: string;
99
+ }
100
+ /**
101
+ * The gate tree with each node's verdict — the same shape as `criteria.gate`, so a client can
102
+ * render the real requirement ("the Pass AND not banned", "the Pass OR moderator") instead of a
103
+ * flat list that cannot express it.
104
+ */
105
+ export type EligibilityNode = ({
106
+ kind: "leaf";
107
+ } & EligibilityCheck) | {
108
+ kind: "all" | "any";
109
+ satisfied: boolean | undefined;
110
+ children: EligibilityNode[];
111
+ };
112
+ /**
113
+ * What {@link Contest.checkEligibility} found.
114
+ *
115
+ * `checks` is every rule in the gate, in document order. `failures` is the subset that EXPLAINS a
116
+ * refusal, which is not the same thing: a rule that failed inside a satisfied `any` branch cost
117
+ * the wallet nothing, and telling someone to go acquire an asset they do not need is worse than
118
+ * saying nothing. Render `failures`; use `gate` when the shape of the requirement matters.
71
119
  */
72
120
  export type EligibilityResult = {
73
121
  eligible: true;
74
- /** The wallet's gate score, `> 0n`. For a balance gate this is the holding itself. */
122
+ /** The gate's folded score (min across an `all`, max across an `any`), always `> 0n`. */
75
123
  score: bigint;
124
+ readonly checks: readonly EligibilityCheck[];
125
+ readonly gate: EligibilityNode;
76
126
  } | {
77
127
  eligible: false;
78
- /** The rule's own explanation, written for the voter. Render it verbatim. */
128
+ /** The blame set's reasons joined — the one-string form, for a caller that wants one. */
79
129
  error: string;
130
+ /** The rules whose failure explains the refusal. Never empty on this branch. */
131
+ readonly failures: readonly EligibilityCheck[];
132
+ readonly checks: readonly EligibilityCheck[];
133
+ readonly gate: EligibilityNode;
80
134
  };
81
135
  export interface Contest {
82
136
  /** The criteria document this contest runs (already validated). */
@@ -100,24 +154,30 @@ export interface Contest {
100
154
  getTally(): Promise<ContestTally>;
101
155
  /**
102
156
  * Would this contest's gate admit `address` right now? Ask before signing, to tell a voter
103
- * whether their ballot will count — and, when it will not, exactly why.
157
+ * whether their ballot will count — and, when it will not, exactly which rules refused.
104
158
  *
105
- * This runs the contest's REAL gate rule through the same context the forward-gate and the
106
- * background verifier use: the same chain client, the same coalesced head reader, the same
107
- * memo. So it reads at whatever block the rule reads at, applies whatever threshold the rule
108
- * applies, and returns the rule's own {@link RuleResult.error} wording verbatim. A client
109
- * renders `error` and needs to know nothing about blocks, buckets or thresholds — which is
159
+ * This runs the contest's REAL gate rules through the same context the forward-gate and the
160
+ * background verifier use: the same chain clients, the same coalesced head reader, the same
161
+ * memos. So it reads at whatever block each rule reads at, applies whatever thresholds they
162
+ * apply, and returns their own {@link RuleResult.error} wording verbatim. A client renders
163
+ * those sentences and needs to know nothing about blocks, buckets or thresholds — which is
110
164
  * the point: re-deriving any of that outside the rule is how a UI ends up confidently
111
165
  * telling voters to wait for a window that no longer gates anything.
112
166
  *
167
+ * Every leaf is evaluated, even once the outcome is settled, because naming each failure is
168
+ * what this call is for (the forward gate, which only needs the verdict, stops early instead).
169
+ * A rule whose chain read fails comes back `satisfied: undefined` rather than sinking the
170
+ * whole answer — a wallet admitted by a branch that did answer is still told so. It throws
171
+ * only when the gate cannot be decided without the rule that failed.
172
+ *
113
173
  * It is a courtesy check, not a promise. Eligibility is a fact about the chain and can change
114
174
  * between this call and the publish, and each peer verifies against its own view — so a
115
175
  * `true` here can still be followed by a `VoteEvictedError` (which carries the same kind of
116
176
  * reason). `publish()` deliberately does NOT call this: the gate is the network's decision,
117
177
  * and refusing locally would only hide a vote the rest of the topic would have accepted.
118
178
  *
119
- * Costs one gate evaluation, usually served from the shared memo — the same read the verifier
120
- * would do anyway, not an extra one.
179
+ * Costs one evaluation per gate rule, usually served from the shared memos — the same reads
180
+ * the verifier would do anyway, not extra ones.
121
181
  */
122
182
  checkEligibility(args: {
123
183
  address: string;
@@ -228,10 +288,10 @@ export interface PubsubVoterOptions {
228
288
  */
229
289
  helia: HeliaInstance;
230
290
  /**
231
- * Resolves a chain named by a contest's criteria (`requires.chains`, ticker + chainId)
232
- * to a viem `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
291
+ * Resolves the chain a contest counts in (`criteria.bucketChainId`) to a viem
292
+ * `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
233
293
  * are deliberately not part of the criteria document, so this factory is where the host
234
- * maps chains to the endpoints it trusts. Return one shared client per chain (memoized),
294
+ * maps chain ids to the endpoints it trusts. Return one shared client per chain (memoized),
235
295
  * and `undefined` for a chain with no RPC configured — `createContest` /
236
296
  * `createContestVote` then throws `MissingChainClientError` (recuse, don't miscount).
237
297
  * See `ChainClientFactory` (src/chain/types.ts) for the full contract.
@@ -3,7 +3,6 @@ import { CriteriaSchema } from "../schema/criteria.js";
3
3
  import { VotesBundleSchema } from "../schema/votes.js";
4
4
  import { coalescingChainFactory } from "../chain/coalescer.js";
5
5
  import { makeBucketMath } from "../chain/bucket.js";
6
- import { tickerForRef } from "../chain/ticker.js";
7
6
  import { requireHeliaServices } from "../transport/helia.js";
8
7
  import { makeBlockstoreBundleStore } from "../transport/bundle-store.js";
9
8
  import { makeRateLimiter } from "../transport/rate-limit.js";
@@ -15,7 +14,7 @@ import { encodeBundle, decodeBundle, bundleCidForBytes } from "../crdt/codec.js"
15
14
  import { resolveRegistry, validateCriteriaRules } from "../rules/registry.js";
16
15
  import { makeVoteCrdt } from "../crdt/crdt.js";
17
16
  import { makePersistentRuleCache } from "../rules/cache.js";
18
- import { gateFailure, scoreOrZero } from "../rules/result.js";
17
+ import { gateBlame, gateLeaves, gateReason, gateScore } from "../rules/gate.js";
19
18
  import { makeBundleVerifier } from "../verify/bundle.js";
20
19
  import { makeVerdictCache } from "../verify/cache.js";
21
20
  import { makeNameResolutionCache } from "../verify/name-resolution-cache.js";
@@ -533,12 +532,10 @@ class ContestEngine {
533
532
  topic;
534
533
  readOnly;
535
534
  #deps;
536
- /** Chain clients for this contest, built from `criteria.requires.chains` via the factory. */
537
- #chainClients;
538
535
  #criteriaCid;
539
- /** The gating (`rule`) chain's numeric chainId, bound into every ballot signature. */
536
+ /** The contest's chain id (`criteria.bucketChainId`), bound into every ballot signature. */
540
537
  #chainId;
541
- /** The gating (`rule`) chain client, also the seed chain for the tally's tie-break block hash. */
538
+ /** The contest's one chain client, also the seed chain for the tally's tie-break block hash. */
542
539
  #ruleChain;
543
540
  #bucketMath;
544
541
  #crdt;
@@ -604,24 +601,16 @@ class ContestEngine {
604
601
  this.readOnly = deps.signer === undefined;
605
602
  this.#deps = deps;
606
603
  this.#criteriaCid = criteriaCidBytes;
607
- // Resolve every chain the manifest requires, eagerly: a client with no RPC configured
608
- // for one of them must find out at the create seam (recuse), not on its first verify.
609
- this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => {
610
- const client = deps.chains({ chain, chainId: config.chainId });
611
- if (client === undefined)
612
- throw new MissingChainClientError(chain, config.chainId);
613
- return [chain, client];
614
- }));
615
- // The gating (`rule`) chain fixes the ballot's chainId and the tie-break seed chain.
616
- const rule = deps.registry[criteria.rule.type];
617
- if (!rule)
618
- throw new UnknownRuleError("rule", criteria.rule.type);
619
- const ruleTicker = tickerForRef(criteria, criteria.rule, rule.optionsSchema.parse(criteria.rule));
620
- const ruleChain = this.#chainClients[ruleTicker];
621
- if (!ruleChain)
622
- throw new Error(`no chain client for gating (\`rule\`) chain "${ruleTicker}"`);
623
- this.#ruleChain = ruleChain;
624
- this.#chainId = criteria.requires.chains[ruleTicker].chainId;
604
+ // The contest's ONE chain (`bucketChainId`): it fixes the ballot's chainId, the blocks the
605
+ // buckets count, the block every rule is handed, and the tie-break seed. Resolved eagerly,
606
+ // so a client with no RPC configured for it finds out at the create seam (and recuses)
607
+ // rather than on its first verify.
608
+ this.#chainId = criteria.bucketChainId;
609
+ const chain = deps.chains({ chainId: this.#chainId });
610
+ if (chain === undefined)
611
+ throw new MissingChainClientError(this.#chainId);
612
+ this.#ruleChain = chain;
613
+ const gateRefs = gateLeaves(criteria.gate);
625
614
  this.#bucketMath = makeBucketMath(criteria.blocksPerBucket);
626
615
  const store = makeBlockstoreBundleStore(deps.blockstore);
627
616
  // The CRDT keeps a superseded bundle alive while its superseder's deferred checks are
@@ -632,40 +621,38 @@ class ContestEngine {
632
621
  voteExpiryBuckets: criteria.voteExpiryBuckets,
633
622
  isProvisional: (cid) => this.#isPending(cid)
634
623
  });
635
- // The gate rule's memo (rules/cache.ts), shared between the inline forward-gate verifier
636
- // and the background chain verifier so neither re-reads what the other settled — layered
637
- // over the voter's persistent store and namespaced by the hash of the canonical rule
638
- // reference + chainId. That hash is exactly the sharing boundary: two contests over one
639
- // gate (a directory of boards on the same Pass) share each other's reads, while
640
- // different gates, or one gate on different options, can never collide. What is stored
641
- // under it, and for how long, is the rule's business, not the engine's.
642
- this.#ruleHash = sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.rule }));
643
- const gateCache = makePersistentRuleCache({ store: deps.gateStore, namespace: this.#ruleHash });
644
- // The weight rule gets its own namespace on the SAME terms — its canonical reference plus
645
- // the id of the chain IT reads, which is not necessarily the gating chain. A ticker is
646
- // just a name local to the criteria document, so two contests can spell the same weight
647
- // ref while `requires.chains` binds that ticker to different chains; keying on the gate's
648
- // chainId would let one serve the other's scores from the wrong chain.
624
+ // One memo per gate leaf (rules/cache.ts), each shared between the inline forward-gate
625
+ // verifier and the background chain verifier so neither re-reads what the other settled —
626
+ // layered over the voter's persistent store and namespaced by the hash of that leaf's
627
+ // canonical rule reference + chainId. That hash is exactly the sharing boundary: two
628
+ // contests over one gate rule (a directory of boards on the same Pass) share each other's
629
+ // reads, while different rules, or one rule on different options, can never collide. What
630
+ // is stored under it, and for how long, is the rule's business, not the engine's. The same
631
+ // hash is each leaf's public `ruleId` in `checkEligibility` — NOT unique within one gate,
632
+ // because a rule may be named in two branches, and two positions of one question SHOULD
633
+ // share a keyspace (see `dedupeLeaves`).
634
+ this.#gateRefs = gateRefs;
635
+ this.#ruleIds = gateRefs.map((ref) => sha256(encodeDagCbor({ chainId: this.#chainId, rule: ref })));
636
+ const gateCaches = this.#ruleIds.map((namespace) => makePersistentRuleCache({ store: deps.gateStore, namespace }));
637
+ // The weight rule gets its own namespace on the same terms: its canonical reference plus
638
+ // this contest's chain id. It reads the same chain as everything else here — a weight rule
639
+ // on a second chain is the same open question as a gate leaf on one.
649
640
  const weight = deps.registry[criteria.weight.type];
650
641
  if (!weight)
651
642
  throw new UnknownRuleError("weight", criteria.weight.type);
652
- const weightTicker = tickerForRef(criteria, criteria.weight, weight.optionsSchema.parse(criteria.weight));
653
- const weightChainId = criteria.requires.chains[weightTicker]?.chainId;
654
- if (weightChainId === undefined)
655
- throw new Error(`no chain client for weight chain "${weightTicker}"`);
656
643
  const weightCache = makePersistentRuleCache({
657
644
  store: deps.gateStore,
658
- namespace: sha256(encodeDagCbor({ chainId: weightChainId, rule: criteria.weight }))
645
+ namespace: sha256(encodeDagCbor({ chainId: this.#chainId, rule: criteria.weight }))
659
646
  });
660
647
  const verifier = makeBundleVerifier({
661
648
  criteria,
662
649
  criteriaCid: criteriaCidBytes,
663
650
  chainId: this.#chainId,
664
651
  registry: deps.registry,
665
- chainFor: (ticker) => this.#chainFor(ticker),
652
+ chain: this.#ruleChain,
666
653
  bucketMath: this.#bucketMath,
667
654
  nameResolvers: deps.nameResolvers,
668
- ruleCache: gateCache,
655
+ ruleCaches: gateCaches,
669
656
  nameResolutionCache: deps.nameResolutionCache,
670
657
  readHead: ({ chain }) => this.#readHead({ chain })
671
658
  });
@@ -678,10 +665,10 @@ class ContestEngine {
678
665
  this.#background = makeBackgroundVerifier({
679
666
  criteria,
680
667
  registry: deps.registry,
681
- chainFor: (ticker) => this.#chainFor(ticker),
668
+ chain: this.#ruleChain,
682
669
  bucketMath: this.#bucketMath,
683
670
  nameResolvers: deps.nameResolvers,
684
- ruleCache: gateCache,
671
+ ruleCaches: gateCaches,
685
672
  nameResolutionCache: deps.nameResolutionCache,
686
673
  readHead: ({ chain }) => this.#readHead({ chain }),
687
674
  cache: this.#cache,
@@ -694,7 +681,7 @@ class ContestEngine {
694
681
  this.#tally = makeTally({
695
682
  criteria,
696
683
  registry: deps.registry,
697
- chainFor: (ticker) => this.#chainFor(ticker),
684
+ chain: this.#ruleChain,
698
685
  bucketMath: this.#bucketMath,
699
686
  readHead: ({ chain }) => this.#readHead({ chain }),
700
687
  ruleCache: weightCache,
@@ -705,8 +692,10 @@ class ContestEngine {
705
692
  });
706
693
  }
707
694
  #store;
708
- /** Hash of the canonical gate rule + chainId — this contest's keyspace in the shared gate store. */
709
- #ruleHash;
695
+ /** Per gate leaf: hash of its canonical rule ref + chainId — its keyspace in the shared gate store. */
696
+ #ruleIds;
697
+ /** The gate's leaf refs in document order, aligned with {@link #ruleIds}. */
698
+ #gateRefs;
710
699
  #cache;
711
700
  #acceptedDedup;
712
701
  #verifier;
@@ -805,12 +794,6 @@ class ContestEngine {
805
794
  emitError(error) {
806
795
  this.#emitError(error);
807
796
  }
808
- #chainFor(ticker) {
809
- const client = this.#chainClients[ticker];
810
- if (!client)
811
- throw new Error(`no chain client configured for chain "${ticker}"`);
812
- return client;
813
- }
814
797
  /**
815
798
  * Read the gating-chain head and update {@link #currentBucketCache}; returns the bucket.
816
799
  *
@@ -873,21 +856,54 @@ class ContestEngine {
873
856
  return sampleBucket <= (await this.#nowBucket());
874
857
  }
875
858
  /**
876
- * Run the gate rule for one wallet, against the ballot block a vote published NOW would carry
877
- * the engine half of {@link Contest.checkEligibility}.
859
+ * Run the gate for one wallet, against the ballot block a vote published NOW would carry
860
+ * the engine half of {@link Contest.checkEligibility}.
878
861
  *
879
- * All the interesting decisions belong to the rule: this resolves the current bucket's sample
880
- * block, hands it over, and translates the rule's own answer. It never looks at what kind of
881
- * rule it is holding, so it stays correct for a head-scoring gate, a pinned one, or anything
882
- * a host registers later.
862
+ * All the interesting decisions belong to the rules: this resolves the current bucket's sample
863
+ * block, hands it over, and translates their own answers. It never looks at what kind of rules
864
+ * it is holding, so it stays correct for a head-scoring gate, a pinned one, a composite of
865
+ * both, or anything a host registers later.
883
866
  */
884
867
  async checkEligibility({ address }) {
885
868
  const sampleBlock = this.#bucketMath.sampleBlockForBucket(await this.#nowBucket());
886
- const result = await this.#verifier.checkGate({ address, sampleBlock });
887
- const failed = gateFailure(result);
888
- return failed ? { eligible: false, error: failed.error } : { eligible: true, score: scoreOrZero(result) };
869
+ const gate = await this.#verifier.checkGates({ address, sampleBlock });
870
+ const checks = new Map();
871
+ const node = this.#toEligibilityNode(gate, checks);
872
+ // Document order, not evaluation order: the leaves as the criteria lists them.
873
+ const ordered = [...checks.entries()].sort(([a], [b]) => a - b).map(([, check]) => check);
874
+ if (gate.satisfied === true)
875
+ return { eligible: true, score: gateScore(gate), checks: ordered, gate: node };
876
+ return {
877
+ eligible: false,
878
+ error: gateReason(gate),
879
+ // The blame set, not every failed leaf: a failure inside a satisfied `any` explains
880
+ // nothing the voter can act on (rules/gate.ts `gateBlame`).
881
+ failures: gateBlame(gate).map((leaf) => checks.get(leaf.leaf)),
882
+ checks: ordered,
883
+ gate: node
884
+ };
885
+ }
886
+ /** Project one evaluated gate node onto the public shape, collecting its leaves by index. */
887
+ #toEligibilityNode(result, checks) {
888
+ if (result.kind !== "leaf") {
889
+ return {
890
+ kind: result.kind,
891
+ satisfied: result.satisfied,
892
+ children: result.children.map((child) => this.#toEligibilityNode(child, checks))
893
+ };
894
+ }
895
+ const check = {
896
+ leaf: result.leaf,
897
+ ruleId: this.#ruleIds[result.leaf],
898
+ type: this.#gateRefs[result.leaf].type,
899
+ satisfied: result.satisfied,
900
+ score: result.score,
901
+ ...(result.error === undefined ? {} : { error: result.error })
902
+ };
903
+ checks.set(result.leaf, check);
904
+ return { kind: "leaf", ...check };
889
905
  }
890
- /** Hash of the current bucket boundary block on the gating (`rule`) chain (rolling tie seed). */
906
+ /** Hash of the current bucket boundary block on the contest's chain (rolling tie seed). */
891
907
  async #bucketBlockHash() {
892
908
  const head = await this.#ruleChain.getBlockNumber();
893
909
  const boundary = this.#bucketMath.sampleBlockForBucket(this.#bucketMath.bucketForBlock(Number(head)));
package/dist/errors.d.ts CHANGED
@@ -15,26 +15,24 @@ export declare class NotImplementedError extends Error {
15
15
  }
16
16
  /**
17
17
  * Thrown when a criteria document names a rule `type` this client does not
18
- * implement (in the `rule`/`weight` slot or in `requires.rules`). A
18
+ * implement (in a `gate` leaf, the `weight` slot, or in `requires.rules`). A
19
19
  * client that hits this is too old (or missing a host override) and must recuse
20
20
  * itself from the contest rather than miscount. See DESIGN.md "Rules".
21
21
  */
22
22
  export declare class UnknownRuleError extends Error {
23
- readonly slot: "rule" | "weight" | "requires";
23
+ readonly slot: "gate" | "weight" | "requires";
24
24
  readonly type: string;
25
- constructor(slot: "rule" | "weight" | "requires", type: string);
25
+ constructor(slot: "gate" | "weight" | "requires", type: string);
26
26
  }
27
27
  /**
28
- * Thrown by `createContest` / `createContestVote` when the criteria's dependency manifest
29
- * names a chain (`requires.chains`) the host's `ChainClientFactory` cannot resolve to a
30
- * client (it returned `undefined`). RPC endpoints are client-local settings, not part of
28
+ * Thrown by `createContest` / `createContestVote` when the host's `ChainClientFactory` cannot
29
+ * resolve the contest's `bucketChainId` to a client (it returned `undefined`). RPC endpoints are client-local settings, not part of
31
30
  * the criteria document, so a client with no gateway configured for a required chain must
32
31
  * recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
33
32
  */
34
33
  export declare class MissingChainClientError extends Error {
35
- readonly chain: string;
36
34
  readonly chainId: number;
37
- constructor(chain: string, chainId: number);
35
+ constructor(chainId: number);
38
36
  }
39
37
  /**
40
38
  * Thrown at construction when the injected Helia node's libp2p has no usable pubsub
package/dist/errors.js CHANGED
@@ -16,7 +16,7 @@ export class NotImplementedError extends Error {
16
16
  }
17
17
  /**
18
18
  * Thrown when a criteria document names a rule `type` this client does not
19
- * implement (in the `rule`/`weight` slot or in `requires.rules`). A
19
+ * implement (in a `gate` leaf, the `weight` slot, or in `requires.rules`). A
20
20
  * client that hits this is too old (or missing a host override) and must recuse
21
21
  * itself from the contest rather than miscount. See DESIGN.md "Rules".
22
22
  */
@@ -33,21 +33,18 @@ export class UnknownRuleError extends Error {
33
33
  }
34
34
  }
35
35
  /**
36
- * Thrown by `createContest` / `createContestVote` when the criteria's dependency manifest
37
- * names a chain (`requires.chains`) the host's `ChainClientFactory` cannot resolve to a
38
- * client (it returned `undefined`). RPC endpoints are client-local settings, not part of
36
+ * Thrown by `createContest` / `createContestVote` when the host's `ChainClientFactory` cannot
37
+ * resolve the contest's `bucketChainId` to a client (it returned `undefined`). RPC endpoints are client-local settings, not part of
39
38
  * the criteria document, so a client with no gateway configured for a required chain must
40
39
  * recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
41
40
  */
42
41
  export class MissingChainClientError extends Error {
43
- chain;
44
42
  chainId;
45
- constructor(chain, chainId) {
46
- super(`No chain client for "${chain}" (chainId ${chainId}), which this contest's criteria ` +
47
- `requires. RPC endpoints are client settings, not part of the criteria document: ` +
48
- `configure the \`chains\` factory (PubsubVoterOptions.chains) to return a viem ` +
49
- `PublicClient for this chain, or recuse this contest.`);
50
- this.chain = chain;
43
+ constructor(chainId) {
44
+ super(`No chain client for chainId ${chainId}, which this contest counts its buckets in ` +
45
+ `(\`criteria.bucketChainId\`). RPC endpoints are client settings, not part of the ` +
46
+ `criteria document: configure the \`chains\` factory (PubsubVoterOptions.chains) to ` +
47
+ `return a viem PublicClient for this chain, or recuse this contest.`);
51
48
  this.chainId = chainId;
52
49
  this.name = "MissingChainClientError";
53
50
  }