@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.
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
 
@@ -34,18 +34,28 @@ See [DESIGN.md](./DESIGN.md) for the full rationale, including how this resists
34
34
 
35
35
  ## Usage
36
36
 
37
- The library never starts a node and never takes a host SDK (there is no `pkc` argument). A host passes its own running Helia node in directly and injects its seams into a single `PubsubVoter`:
37
+ The library never starts a node and never takes a host SDK (there is no `pkc` argument). A host passes its own running Helia node in directly and injects its seams into a single `PubsubVoter`. **Identity is not one of the seams**: the voting wallet (`VoteSigner`) belongs to each ballot, passed to `createContestVote`, so one voter on the host's shared node publishes for as many wallets as the host holds keys for — and a client that only renders tallies never touches key material.
38
38
 
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) |
43
- | `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
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) |
44
43
  | `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
44
  | `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
45
  | `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
46
 
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.
47
+ 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.
48
+
49
+ Who may vote is the document's `gate` — one rule, or a boolean tree of them:
50
+
51
+ ```ts
52
+ gate: { rule: { type: "erc5192-min-balance", contract: "0x13d4…91b9", min: 1 } }
53
+ gate: { all: [{ rule: passRule }, { rule: notBannedRule }] } // every rule must admit
54
+ gate: { any: [{ rule: passRule }, { rule: moderatorRule }] } // any one of them admits
55
+ gate: { all: [{ any: [{ rule: passRule }, { rule: moderatorRule }] }, { rule: notBannedRule }] }
56
+ ```
57
+
58
+ 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
59
 
50
60
  ### Construct a voter
51
61
 
@@ -61,13 +71,12 @@ const viemChainFactory = (): ChainClientFactory => {
61
71
  const clients: Record<number, ReturnType<typeof createPublicClient>> = {
62
72
  [base.id]: createPublicClient({ chain: base, transport: http("https://my-trusted-base-rpc.example") })
63
73
  };
64
- return ({ chainId }) => clients[chainId]; // undefined → recuse contests requiring that chain
74
+ return ({ chainId }) => clients[chainId]; // undefined → recuse contests counting in that chain
65
75
  };
66
76
 
67
77
  const voter = new PubsubVoter({
68
78
  helia, // the host's Helia node; needs a gossipsub service at libp2p.services.pubsub + a blockstore
69
- chains: viemChainFactory(), // ({ chain, chainId }) => viem PublicClient | undefined
70
- signer: mySigner, // optional; omit → read-only voter
79
+ chains: viemChainFactory(), // ({ chainId }) => viem PublicClient | undefined
71
80
  nameResolvers: [bsoResolver], // optional; verifies community-name claims (e.g. @bitsocial/bso-resolver)
72
81
  dataPath: "/path/to/data", // optional; persistent state: caches + checkpoint snapshots (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
73
82
  httpRouterUrls: [ // optional, SEEDERS ONLY (publicly reachable node): announce provider
@@ -78,7 +87,7 @@ const voter = new PubsubVoter({
78
87
 
79
88
  Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingFetchError` if the node lacks a usable pubsub service, blockstore, or libp2p fetch service — the library fails fast rather than letting a later `publish`/`subscribe`/`fetch` fail obscurely. ("Bitswap" is not a separately checkable property — it is a block broker wired beneath `blockstore` — so the validated guarantee is a well-formed blockstore, the surface bitswap retrieves through. The fetch service carries the checkpoint root-record pull; the library registers its own responder on it.)
80
89
 
81
- ### Read a tally reactively (no signer needed)
90
+ ### Read a tally reactively
82
91
 
83
92
  `createContest` mints a per-contest read object; `update()` starts syncing and it emits `update` (carrying a fresh `tally`) and `error`, just like a pkc-js `community`:
84
93
 
@@ -93,20 +102,47 @@ await contest.update(); // join the topic, col
93
102
 
94
103
  ### Will this wallet's vote count?
95
104
 
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:
105
+ Ask the contest before signing. `checkEligibility` runs the contest's **real gate rules** through
106
+ the same chain clients, head reader and memos the forward gate uses, so each reads at whatever
107
+ block it reads at and applies whatever threshold it applies. When the gate refuses, the wording is
108
+ the rules' own — render it verbatim:
100
109
 
101
110
  ```ts
102
111
  const check = await contest.checkEligibility({ address: wallet });
103
112
  if (check.eligible) {
104
113
  show(`eligible — holds ${check.score}`);
105
114
  } else {
106
- show(check.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
115
+ for (const failure of check.failures) show(failure.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
107
116
  }
108
117
  ```
109
118
 
119
+ With a composite gate you get one entry per rule, so a client can show a checklist rather than a
120
+ single verdict:
121
+
122
+ | field | what it is |
123
+ | --- | --- |
124
+ | `checks` | every rule in the gate, in document order: `{ leaf, ruleId, type, satisfied, score, error? }` |
125
+ | `failures` | the rules whose failure **explains** the refusal — render these |
126
+ | `gate` | the same tree as `criteria.gate`, each node carrying its `satisfied`, for rendering the real requirement |
127
+ | `error` | `failures` joined into one sentence, for a caller that only wants a string |
128
+
129
+ `failures` is deliberately **not** `checks.filter(c => !c.satisfied)`. Under `any`, a wallet that
130
+ qualifies as a moderator also "fails" the Pass rule — telling it to go and buy a Pass would be
131
+ worse than saying nothing.
132
+
133
+ Key rows by **`leaf`** — the rule's position in the gate, and the only field guaranteed unique in
134
+ one result. `type` is not (a gate may name one rule twice on different options) and neither is
135
+ `ruleId` (a gate may name the same rule in two branches). Use `ruleId` to compare *across* results
136
+ instead: equal ids are one question, so a directory of 63 boards gated on one Pass shows the same
137
+ id everywhere, and shares one chain read behind it.
138
+
139
+ A check's `satisfied` is `true`, `false`, or **`undefined`** — the last meaning that rule's chain
140
+ read failed and the gate was decided without it. Render it as unknown, never as a requirement the
141
+ wallet is missing: nothing was learned about them. A wallet admitted by a branch that did answer
142
+ still gets `eligible: true` while an unrelated contract's RPC is down; the call throws only when
143
+ the gate cannot be decided without the rule that failed, because at that point there is no honest
144
+ answer to give.
145
+
110
146
  Do **not** reimplement this by reading balances yourself: which block counts is the rule's
111
147
  business and changes when the rule changes. A client that hard-codes "peers verify at the bucket
112
148
  boundary" keeps telling voters to wait for a window that a head-reading gate no longer imposes.
@@ -138,31 +174,56 @@ A cold join **renders fast and refines**: checkpoint bundles are admitted after
138
174
 
139
175
  Repeated `createContest` calls with byte-identical criteria return the same `Contest` (engines are keyed by topic, the criteria CID).
140
176
 
141
- ### Publish or withdraw a vote (needs a signer)
177
+ ### Publish or withdraw a vote
142
178
 
143
- `createContestVote` mints a publishable ballot; `publish()` signs and broadcasts it once and emits `publishingstatechange`, like a pkc-js publication:
179
+ `createContestVote` mints a publishable ballot; `publish()` signs and broadcasts it once and emits `publishingstatechange`, like a pkc-js publication. The `signer` is the ballot's — the wallet that holds the Pass and whose recovered address *is* the voter:
144
180
 
145
181
  ```ts
146
- const vote = await voter.createContestVote({ criteria, votes: [{ community: { publicKey: "12D3KooW..." }, vote: 1 }] });
147
- vote.on("publishingstatechange", (state) => console.log(state)); // stopped → signing → publishing → succeeded (or failed)
148
- const { bundle, recipientCount } = await vote.publish(); // the signed VotesBundle + how many peers gossipsub sent it directly to
182
+ const vote = await voter.createContestVote({
183
+ criteria,
184
+ votes: [{ community: { publicKey: "12D3KooW..." }, vote: 1 }],
185
+ signer: mySigner // VoteSigner: address() + signBallot()
186
+ });
187
+ vote.on("publishingstatechange", (state) => console.log(state)); // stopped → signing → publishing → published, then verified-locally and/or verified-by-peer (or failed)
188
+ const { bundle, cid, recipientCount } = await vote.publish(); // the signed VotesBundle, its CID, and how many peers gossipsub sent it directly to
189
+ vote.signer === mySigner; // the ballot carries the wallet it was minted with
149
190
 
150
191
  // Withdraw (active): publish an empty ballot; it supersedes the prior vote under LWW.
151
- await (await voter.createContestVote({ criteria, votes: [] })).publish();
192
+ await (await voter.createContestVote({ criteria, votes: [], signer: mySigner })).publish();
152
193
  ```
153
194
 
195
+ A ballot is required to name its wallet, so a client that holds no key simply never mints one — there is no read-only mode to check, and nothing on an unpublished ballot to render. Two wallets on one voter are two `createContestVote` calls with two signers; the CRDT keys them apart by recovered address, so both land in the tally.
196
+
154
197
  A community's identity is its `publicKey`. The optional `name` is the community's resolvable domain (e.g. `memes.bso`) — unique per community, never a free label: the schema requires a TLD, the name is resolved through the injected `nameResolvers` (inline at the forward-gate for live votes, in the background verifier for cold-join admits), and any bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted. Bundles must also name pairwise-distinct `community.publicKey`s. See [DESIGN.md, Votes wire](./DESIGN.md#votes-wire).
155
198
 
156
199
  `recipientCount` is the peer-reach hint gossipsub reports: how many peers it sent the vote *directly* to at publish time (first-hop fan-out, filtered for send failures) — **not** total network reach, and **not** an acceptance confirmation, since each recipient still runs the forward-gate before re-forwarding. Treat it as a coarse "did this reach anyone?" signal. Note that gossipsub *rejects* the publish with `NoPeersSubscribedToTopic` when it would reach zero peers (common right after joining, before the mesh grafts), unless the host enables `allowPublishToZeroTopicPeers` — so a resolved `recipientCount === 0` only occurs under that host setting; otherwise a no-reach publish surfaces as a thrown error (and a `failed` state).
157
200
 
158
- `publish()` on a voter built without a `signer` throws `ReadOnlyError` (and emits an `error`).
159
-
160
201
  #### Rejection feedback
161
202
 
162
203
  Gossipsub gives a publisher **no acceptance or rejection feedback** — a peer that drops a bundle does so silently. Since every honest peer runs the same checks this node runs, the library turns its own local verdict into the feedback the protocol can't provide, in two places:
163
204
 
164
205
  - **At `publish()`**: each vote's `community.name` is preflighted through the shared resolution cache first — a name that definitively fails (no resolver for its TLD, no record, or it resolves to a **different** `publicKey` than the vote claims) throws `InvalidCommunityNameError` before signing or joining the topic, since every verifier would silently drop that bundle anyway. A resolver that merely *throws* (registry outage) never blocks the publish — the check stays deferred to the background verifier.
165
- - **After `publish()` resolved**: `"succeeded"` means signed and broadcast, **not** accepted by the network. The deferred checks (the on-chain gate read, and any name resolution a preflight outage skipped) run in the background; if one evicts the bundle, the vote emits a `VoteEvictedError` on its `error` event — carrying the evicted `bundle` and the exact `verdict` any verifier would produce — and its `publishingState` flips to `"failed"` post hoc. The same error fires on the contest's `error` event, for long-lived views.
206
+ - **After `publish()` resolved**: `"published"` means signed and broadcast, **not** accepted by the network. The deferred checks (the on-chain gate read, and any name resolution a preflight outage skipped) run in the background; if one evicts the bundle, the vote emits a `VoteEvictedError` on its `error` event — carrying the evicted `bundle` and the exact `verdict` any verifier would produce — and its `publishingState` flips to `"failed"` post hoc. The same error fires on the contest's `error` event, for long-lived views.
207
+
208
+ The positive verdicts are states too, so a client never has to infer "it counted" from the absence of an error:
209
+
210
+ - `"verified-locally"` — our own deferred checks came back clean for this bundle. Still our verdict, but every honest peer runs byte-identical checks, so it is the strongest inference available without hearing from anyone.
211
+ - `"verified-by-peer"` — a peer advertised a checkpoint containing this bundle. A node serves only fully verified bundles in its own checkpoint, so an honest peer including it implies that peer verified it too; what is *observed* is that somebody other than us is keeping the vote.
212
+
213
+ The two are independent outcomes of two asynchronous races, not a sequence: a peer can serve our bundle back before our own gate read returns, in which case `"verified-by-peer"` is reached directly and `"verified-locally"` is never emitted. Peer evidence is the stronger of the two, so it is never walked back to the local verdict arriving late. Treat both as terminal-positive; do not wait for `"verified-locally"` before `"verified-by-peer"`.
214
+
215
+ Both are readable from the contest by bundle CID, which is how a restored vote asks after a reload — the publishing `ContestVote` is long gone by then, but the vote lives for `voteExpiryBuckets`. Persist `PublishOutcome.cid` and ask with it:
216
+
217
+ ```ts
218
+ contest.checksFor(cid); // { chainVerified, nameResolved? } — or undefined if not held (never admitted, evicted, expired)
219
+ contest.checkpointPeersFor(cid); // peer ids seen serving OUR bundle back in their checkpoint (own bundles only; a lower bound)
220
+ ```
221
+
222
+ `checksFor` needs nothing extra: the checks are recorded on every admit path, including the snapshot restore. **`checkpointPeersFor` does**, because the engine cannot recognise a bundle it did not sign — the signer belongs to the publication, not the contest, so a restored bundle looks like any other wallet's. A client that persisted the CID re-arms attribution once, after `update()`:
223
+
224
+ ```ts
225
+ contest.trackOwnBundle(cid); // idempotent; attribution runs from here forward, not backwards
226
+ ```
166
227
 
167
228
  ```ts
168
229
  vote.on("error", (err) => {
@@ -173,7 +234,7 @@ await vote.publish(); // throws InvalidCommunityNameError if a carried name can'
173
234
 
174
235
  ### Republishing is the client's job
175
236
 
176
- A vote is not permanent: a bundle is valid only for `voteExpiryBuckets` after its `blockNumber`, so a live vote must be re-published before it decays. **This library does not do that automatically** — it publishes each vote once and the consuming client decides when (or whether) to refresh. To refresh, just `createContestVote(...).publish()` again; a new bundle at the current bucket supersedes the old one. To stop, simply stop refreshing and let the vote lapse. The library gives you what you need to schedule it — all pure, no chain reads:
237
+ A vote is not permanent: a bundle is valid only for `voteExpiryBuckets` after its `blockNumber`, so a live vote must be re-published before it decays. **This library does not do that automatically** — it publishes each vote once and the consuming client decides when (or whether) to refresh. To refresh, just `createContestVote(...).publish()` again (with the same signer); a new bundle at the current bucket supersedes the old one. To stop, simply stop refreshing and let the vote lapse. The library gives you what you need to schedule it — all pure, no chain reads:
177
238
 
178
239
  ```ts
179
240
  import { republishIntervalBuckets } from "@bitsocial/pubsub-voting";
@@ -207,7 +268,7 @@ There is no separate seeder API: a node that joins a topic (via `update()` or `p
207
268
  `stop()` leaves every joined topic but keeps the voter **reusable** — each `Contest` can `update()` again and you can `createContest` afterward. `destroy()` is **terminal** (like pkc-js): it leaves every topic, unregisters the fetch responder, and marks the voter and its contests dead — any later `createContest`/`createContestVote`, or a pre-existing `Contest.update()`/`ContestVote.publish()`, throws `VoterDestroyedError`. Construct a new `PubsubVoter` to participate again. (There is no store to dispose — republishing is the client's concern.)
208
269
 
209
270
  ```ts
210
- const voter = new PubsubVoter({ helia, chains, signer });
271
+ const voter = new PubsubVoter({ helia, chains });
211
272
  // … create + update contests, app runs …
212
273
  await voter.destroy(); // terminal: leave all topics, unregister the responder, forbid reuse
213
274
  ```
@@ -225,7 +286,7 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
225
286
 
226
287
  ### Custom rules
227
288
 
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).)
289
+ 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
290
 
230
291
  Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
231
292
 
@@ -298,6 +359,8 @@ type RuleResult =
298
359
 
299
360
  **`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
361
 
362
+ 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.
363
+
301
364
  `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
365
 
303
366
  ### 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
  /**
@@ -3,6 +3,8 @@ import { type Vote, type VotesBundle } from "../schema/votes.js";
3
3
  import type { ChainClient, ChainClientFactory, NameResolver } from "../chain/types.js";
4
4
  import type { HeliaInstance } from "../transport/types.js";
5
5
  import type { RuleRegistry } from "../rules/types.js";
6
+ import type { BundleChecks } from "../verify/types.js";
7
+ import { CID } from "multiformats/cid";
6
8
  import type { ContestTally } from "../tally/types.js";
7
9
  import type { VoteSigner } from "../signer/types.js";
8
10
  /**
@@ -17,7 +19,7 @@ import type { VoteSigner } from "../signer/types.js";
17
19
  * `blockNumber` on a published `VotesBundle`, and `criteria.voteExpiryBuckets` /
18
20
  * `criteria.blocksPerBucket` are what a client uses to schedule its own refreshes: a vote sampled
19
21
  * at bucket `b` expires once the current bucket exceeds `b + voteExpiryBuckets`; refresh by
20
- * calling `createContestVote({ criteria, votes }).publish()` again before then.
22
+ * calling `createContestVote({ criteria, votes, signer }).publish()` again before then.
21
23
  */
22
24
  export declare function republishIntervalBuckets(criteria: Criteria): number;
23
25
  /**
@@ -42,18 +44,41 @@ export declare function republishIntervalBuckets(criteria: Criteria): number;
42
44
  * {@link republishIntervalBuckets} and DESIGN.md "Republishing is the client's job").
43
45
  */
44
46
  /**
45
- * A vote publication's lifecycle, walked by {@link ContestVote.publish}. `"succeeded"` means
46
- * signed, admitted locally, and broadcast NOT accepted by the network (gossipsub gives a
47
- * publisher no acceptance/rejection feedback). It can therefore still flip to `"failed"`
48
- * afterwards: if this node's own deferred checks — the same checks every peer runs — evict the
49
- * bundle, the vote emits a `VoteEvictedError` and fails post hoc (see DESIGN.md "Background
50
- * chain verification", publisher feedback).
47
+ * A vote publication's lifecycle, walked by {@link ContestVote.publish}, then continued by the
48
+ * deferred checks that settle after it resolves. Three states describe a bundle that is on the
49
+ * wire, and they are deliberately not synonyms:
50
+ *
51
+ * - `"published"` — signed, admitted to our OWN local set, and broadcast. It says what we did,
52
+ * and nothing about anyone else: gossipsub gives a publisher no acceptance feedback, so an
53
+ * ineligible wallet reaches this state exactly like an eligible one. (This state was called
54
+ * `"succeeded"` before 0.6.0, a name borrowed from pkc-js — where a publish really is
55
+ * acknowledged, by the challenge exchange. Nothing acknowledges a vote here.)
56
+ * - `"verified-locally"` — OUR deferred checks came back clean for this bundle: the gate rule
57
+ * scored the wallet `> 0n` at its bucket block, and every carried `community.name` resolved
58
+ * to the key it claimed. Still our own verdict — but every honest peer runs byte-identical
59
+ * checks, so it is the strongest inference a publisher can draw without hearing from anyone.
60
+ * - `"verified-by-peer"` — a peer advertised a checkpoint that contained this bundle. Since a
61
+ * node serves only fully verified bundles in its own checkpoint, an honest peer including it
62
+ * implies that peer verified it too; what we OBSERVE is that somebody other than us kept the
63
+ * vote. See {@link Contest.checkpointPeersFor} for who, and its caveats.
64
+ *
65
+ * `"failed"` is the counterpart of `"verified-locally"`: if the deferred checks EVICT the bundle
66
+ * the vote emits a `VoteEvictedError` and fails post hoc (see DESIGN.md "Background chain
67
+ * verification", publisher feedback). The two verified states are reached in order and neither is
68
+ * reached after `"failed"` — an evicted bundle is gone from the set that checkpoints are cut from.
51
69
  */
52
- export type PublishingState = "stopped" | "signing" | "publishing" | "succeeded" | "failed";
70
+ export type PublishingState = "stopped" | "signing" | "publishing" | "published" | "verified-locally" | "verified-by-peer" | "failed";
53
71
  /** What {@link ContestVote.publish} resolves: the signed bundle plus a peer-reach hint. */
54
72
  export interface PublishOutcome {
55
73
  /** The signed bundle; its `blockNumber` drives the client's own refresh schedule. */
56
74
  readonly bundle: VotesBundle;
75
+ /**
76
+ * The bundle's CID — the key its deferred-check state and peer attribution are tracked
77
+ * under. Worth persisting alongside a stored vote: after a reload the `ContestVote` that
78
+ * published it is gone, and this is what {@link Contest.checksFor} /
79
+ * {@link Contest.checkpointPeersFor} are asked with.
80
+ */
81
+ readonly cid: CID;
57
82
  /**
58
83
  * How many peers gossipsub sent this vote *directly* to — first-hop fan-out, not total network
59
84
  * reach and not an acceptance confirmation (each recipient still runs the forward-gate before
@@ -64,19 +89,73 @@ export interface PublishOutcome {
64
89
  */
65
90
  readonly recipientCount: number;
66
91
  }
67
- /** One contest's reactive read view: subscribe, keep the tally in sync, read it. */
68
92
  /**
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.
93
+ * One rule's answer about one wallet, as {@link Contest.checkEligibility} reports it.
94
+ * Shaped like {@link RuleResult} on purpose it is that result, surfaced so a failure always
95
+ * carries a sentence a client can display.
96
+ */
97
+ export interface EligibilityCheck {
98
+ /**
99
+ * This leaf's position in the gate, depth-first in document order — the render key, and the
100
+ * only field guaranteed unique within one result. `type` is not (a gate may name one rule
101
+ * twice on different options) and neither is `ruleId` (a gate may name the SAME rule in two
102
+ * branches, which is how "any two of these three" is written).
103
+ */
104
+ readonly leaf: number;
105
+ /**
106
+ * Stable identity of this rule INSTANCE — the hash of its canonical criteria reference plus
107
+ * the id of the chain it reads, which is also the namespace of its memo. Equal ids mean one
108
+ * question, so they are what to compare ACROSS results: two contests of a 63-board directory
109
+ * gated on one Pass share this id, and share every chain read behind it. Within one gate two
110
+ * leaves may share it; use {@link leaf} to tell them apart.
111
+ */
112
+ readonly ruleId: string;
113
+ /** The rule `type` this leaf names, for grouping and labelling. */
114
+ readonly type: string;
115
+ /**
116
+ * `undefined` when this rule has no answer: its chain read failed and the gate was decided
117
+ * without it. Render it as unknown, never as a failure — nothing was learned about the wallet.
118
+ */
119
+ readonly satisfied: boolean | undefined;
120
+ /** This rule's score for the wallet; `0n` unless it passed. */
121
+ readonly score: bigint;
122
+ /** The rule's own explanation, written for the voter. Render it verbatim. */
123
+ readonly error?: string;
124
+ }
125
+ /**
126
+ * The gate tree with each node's verdict — the same shape as `criteria.gate`, so a client can
127
+ * render the real requirement ("the Pass AND not banned", "the Pass OR moderator") instead of a
128
+ * flat list that cannot express it.
129
+ */
130
+ export type EligibilityNode = ({
131
+ kind: "leaf";
132
+ } & EligibilityCheck) | {
133
+ kind: "all" | "any";
134
+ satisfied: boolean | undefined;
135
+ children: EligibilityNode[];
136
+ };
137
+ /**
138
+ * What {@link Contest.checkEligibility} found.
139
+ *
140
+ * `checks` is every rule in the gate, in document order. `failures` is the subset that EXPLAINS a
141
+ * refusal, which is not the same thing: a rule that failed inside a satisfied `any` branch cost
142
+ * the wallet nothing, and telling someone to go acquire an asset they do not need is worse than
143
+ * saying nothing. Render `failures`; use `gate` when the shape of the requirement matters.
71
144
  */
72
145
  export type EligibilityResult = {
73
146
  eligible: true;
74
- /** The wallet's gate score, `> 0n`. For a balance gate this is the holding itself. */
147
+ /** The gate's folded score (min across an `all`, max across an `any`), always `> 0n`. */
75
148
  score: bigint;
149
+ readonly checks: readonly EligibilityCheck[];
150
+ readonly gate: EligibilityNode;
76
151
  } | {
77
152
  eligible: false;
78
- /** The rule's own explanation, written for the voter. Render it verbatim. */
153
+ /** The blame set's reasons joined — the one-string form, for a caller that wants one. */
79
154
  error: string;
155
+ /** The rules whose failure explains the refusal. Never empty on this branch. */
156
+ readonly failures: readonly EligibilityCheck[];
157
+ readonly checks: readonly EligibilityCheck[];
158
+ readonly gate: EligibilityNode;
80
159
  };
81
160
  export interface Contest {
82
161
  /** The criteria document this contest runs (already validated). */
@@ -100,28 +179,75 @@ export interface Contest {
100
179
  getTally(): Promise<ContestTally>;
101
180
  /**
102
181
  * 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.
182
+ * whether their ballot will count — and, when it will not, exactly which rules refused.
104
183
  *
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
184
+ * This runs the contest's REAL gate rules through the same context the forward-gate and the
185
+ * background verifier use: the same chain clients, the same coalesced head reader, the same
186
+ * memos. So it reads at whatever block each rule reads at, applies whatever thresholds they
187
+ * apply, and returns their own {@link RuleResult.error} wording verbatim. A client renders
188
+ * those sentences and needs to know nothing about blocks, buckets or thresholds — which is
110
189
  * the point: re-deriving any of that outside the rule is how a UI ends up confidently
111
190
  * telling voters to wait for a window that no longer gates anything.
112
191
  *
192
+ * Every leaf is evaluated, even once the outcome is settled, because naming each failure is
193
+ * what this call is for (the forward gate, which only needs the verdict, stops early instead).
194
+ * A rule whose chain read fails comes back `satisfied: undefined` rather than sinking the
195
+ * whole answer — a wallet admitted by a branch that did answer is still told so. It throws
196
+ * only when the gate cannot be decided without the rule that failed.
197
+ *
113
198
  * It is a courtesy check, not a promise. Eligibility is a fact about the chain and can change
114
199
  * between this call and the publish, and each peer verifies against its own view — so a
115
200
  * `true` here can still be followed by a `VoteEvictedError` (which carries the same kind of
116
201
  * reason). `publish()` deliberately does NOT call this: the gate is the network's decision,
117
202
  * and refusing locally would only hide a vote the rest of the topic would have accepted.
118
203
  *
119
- * Costs one gate evaluation, usually served from the shared memo — the same read the verifier
120
- * would do anyway, not an extra one.
204
+ * Costs one evaluation per gate rule, usually served from the shared memos — the same reads
205
+ * the verifier would do anyway, not extra ones.
121
206
  */
122
207
  checkEligibility(args: {
123
208
  address: string;
124
209
  }): Promise<EligibilityResult>;
210
+ /**
211
+ * The deferred-check state of one bundle by CID, or `undefined` if this contest is not
212
+ * holding that bundle (never admitted, evicted, or expired — the three are not
213
+ * distinguishable here, and none of them means "counted").
214
+ *
215
+ * The publisher's question after a reload. A `ContestVote` reports its own bundle's progress
216
+ * through `publishingstatechange`, but that object dies with the page, while a vote lives for
217
+ * `voteExpiryBuckets` — so a tab that restored a vote from its own storage asks here instead,
218
+ * with the CID from {@link PublishOutcome.cid}.
219
+ *
220
+ * `{ chainVerified: true, nameResolved: true | undefined }` is the fully settled state (the
221
+ * same condition that drives `"verified-locally"`). `chainVerified: false` means NOT YET
222
+ * READ, never "failed" — a failed gate evicts the bundle, and this returns `undefined`.
223
+ */
224
+ checksFor(cid: CID): BundleChecks | undefined;
225
+ /**
226
+ * The peers seen advertising a checkpoint that contained `cid` — for THIS wallet's own
227
+ * published bundles only (see DESIGN.md "Checkpoints"). Empty for anything else, including
228
+ * other wallets' bundles, which this node deliberately does not index.
229
+ *
230
+ * What it proves, exactly: a node serves only fully verified bundles in its own checkpoint,
231
+ * so an honest peer including ours implies that peer verified it too — but honesty is not
232
+ * provable, and a peer may serve whatever it likes. So read this as evidence of RETENTION
233
+ * and propagation ("somebody other than us is keeping this vote"), and only inferentially of
234
+ * verification. It is also a lower bound in a second way: it counts peers whose checkpoints
235
+ * we happened to chase, not every peer holding the vote.
236
+ */
237
+ checkpointPeersFor(cid: CID): string[];
238
+ /**
239
+ * Declare that `cid` is one of THIS client's own published bundles, so peer-checkpoint
240
+ * attribution starts tracking it. Only needed after a restart: within one session
241
+ * `publish()` registers its own bundle, but the engine cannot recognise a bundle it did not
242
+ * sign — the signer belongs to the publication, not to the contest, and a restored bundle
243
+ * arrives through the snapshot like any other wallet's.
244
+ *
245
+ * So a client that persisted {@link PublishOutcome.cid} calls this after `update()` to
246
+ * re-arm {@link checkpointPeersFor}. Attribution runs from the call forward, not backwards:
247
+ * the first peer credited is the next one seen advertising a checkpoint that holds the
248
+ * bundle — at most one heartbeat interval away on a live topic. Idempotent.
249
+ */
250
+ trackOwnBundle(cid: CID): void;
125
251
  /**
126
252
  * Fired when incoming votes change the state; `tally` carries the freshly recomputed
127
253
  * ranking. Background check settlements fire it too: a cold join emits a first tally with
@@ -146,21 +272,34 @@ export interface ContestVote {
146
272
  readonly topic: string;
147
273
  /** The votes this ballot will sign and broadcast (empty array = a withdrawal). */
148
274
  readonly votes: readonly Vote[];
275
+ /**
276
+ * The wallet that signs this ballot. Identity is per-ballot, not per-voter: one
277
+ * `PubsubVoter` on the host's shared node publishes for as many wallets as the host holds
278
+ * keys for, and the address recovered from the signature is the only voter identity there
279
+ * is (see DESIGN.md "Identity: the voting wallet, nothing else").
280
+ */
281
+ readonly signer: VoteSigner;
149
282
  /** Where in the publish lifecycle this ballot is. */
150
283
  readonly publishingState: PublishingState;
151
284
  /** The signed bundle, once `publish()` has produced it (`undefined` before then). */
152
285
  readonly bundle: VotesBundle | undefined;
286
+ /**
287
+ * This ballot's deferred-check state — which of the two network checks have settled — or
288
+ * `undefined` before it is signed, and once the bundle has left the working set (evicted or
289
+ * expired). The detail behind `"verified-locally"`, for a UI that wants to say WHICH check is
290
+ * still outstanding rather than just "pending".
291
+ */
292
+ readonly checks: BundleChecks | undefined;
153
293
  /**
154
294
  * Sign the votes into a bundle for the current bucket, add it to the local CRDT, and broadcast
155
295
  * it once as a live delta. Joins the topic first if needed. Resolves a {@link PublishOutcome}:
156
296
  * the `VotesBundle` (whose `blockNumber` the client uses to schedule its own refresh — see
157
297
  * {@link republishIntervalBuckets}) plus `recipientCount`, the number of peers gossipsub sent
158
298
  * the vote directly to. Emits `publishingstatechange` as it goes; throws (and emits `error`) on
159
- * failure: `ReadOnlyError` with no signer, and `InvalidCommunityNameError` when a vote's
160
- * carried `community.name` definitively does not resolve to its claimed `publicKey` (checked
161
- * BEFORE signing or joining — every verifier drops such a bundle silently, so it is refused
162
- * here instead of published into a network-wide silent drop; a resolver outage does not block
163
- * the publish). This library does not re-publish: to keep the vote alive, call `publish()`
299
+ * failure: `InvalidCommunityNameError` when a vote's carried `community.name` definitively does
300
+ * not resolve to its claimed `publicKey` (checked BEFORE signing or joining — every verifier
301
+ * drops such a bundle silently, so it is refused here instead of published into a network-wide
302
+ * silent drop; a resolver outage does not block the publish). This library does not re-publish: to keep the vote alive, call `publish()`
164
303
  * again before it expires.
165
304
  */
166
305
  publish(): Promise<PublishOutcome>;
@@ -177,8 +316,6 @@ export interface ContestVote {
177
316
  }
178
317
  /** The factory: one set of injected dependencies, many contests. */
179
318
  export interface VoteClient {
180
- /** True when constructed without a signer: every contest is read-only. */
181
- readonly readOnly: boolean;
182
319
  /**
183
320
  * Create the reactive read view for one contest from its full criteria document. The document
184
321
  * is strictly validated (`CriteriaSchema` + the rule registry — an unimplemented rule throws
@@ -193,10 +330,16 @@ export interface VoteClient {
193
330
  * Create a publishable ballot for one contest, validated and addressed exactly like
194
331
  * `createContest`. Each call returns a fresh `ContestVote` over the shared per-topic engine;
195
332
  * pass `votes: []` to build a withdrawal.
333
+ *
334
+ * The `signer` is per-ballot, and required. Identity belongs to the vote rather than to the
335
+ * client, so one voter on the host's shared node can publish for several wallets; and a
336
+ * ballot that cannot be signed has nothing to show (every other field on it is
337
+ * publish-derived), so reading a contest is `createContest`'s job, not this one's.
196
338
  */
197
339
  createContestVote(args: {
198
340
  criteria: Criteria;
199
341
  votes: Vote[];
342
+ signer: VoteSigner;
200
343
  }): Promise<ContestVote>;
201
344
  /**
202
345
  * Leave every topic this client joined, resetting each read view so it can `update()` again.
@@ -228,17 +371,15 @@ export interface PubsubVoterOptions {
228
371
  */
229
372
  helia: HeliaInstance;
230
373
  /**
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
374
+ * Resolves the chain a contest counts in (`criteria.bucketChainId`) to a viem
375
+ * `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
233
376
  * 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),
377
+ * maps chain ids to the endpoints it trusts. Return one shared client per chain (memoized),
235
378
  * and `undefined` for a chain with no RPC configured — `createContest` /
236
379
  * `createContestVote` then throws `MissingChainClientError` (recuse, don't miscount).
237
380
  * See `ChainClientFactory` (src/chain/types.ts) for the full contract.
238
381
  */
239
382
  chains: ChainClientFactory;
240
- /** Identity. Omit for a read-only voter (renders tallies, cannot publish). */
241
- signer?: VoteSigner;
242
383
  /** Rule overrides that shadow built-ins by `type` (a flat `type -> rule` map). */
243
384
  rules?: RuleRegistry;
244
385
  /**
@@ -319,13 +460,13 @@ export declare function makeHeadReader(): (chain: ChainClient) => Promise<bigint
319
460
  export declare class PubsubVoter implements VoteClient {
320
461
  #private;
321
462
  constructor(options: PubsubVoterOptions);
322
- get readOnly(): boolean;
323
463
  createContest(args: {
324
464
  criteria: Criteria;
325
465
  }): Promise<Contest>;
326
466
  createContestVote(args: {
327
467
  criteria: Criteria;
328
468
  votes: Vote[];
469
+ signer: VoteSigner;
329
470
  }): Promise<ContestVote>;
330
471
  stop(): Promise<void>;
331
472
  destroy(): Promise<void>;