@bitsocial/pubsub-voting 0.5.0 → 0.6.1

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
@@ -34,16 +34,15 @@ 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
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
- | `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
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
- | `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 |
45
+ | `httpRouterUrls` | `string[]` | no | Delegated Routing V1 router base URLs to **announce provider records to** (one signed `PUT /routing/v1/providers` per router — IPIP-0526: the record carries a `Signature` made by the node's own libp2p key and a fresh `Payload.Timestamp`, both required by [pkc-http-router](https://github.com/pkcprotocol/pkc-http-router), the router implementation this library targets, which rejects the whole PUT with 403 otherwise; `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
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.
49
48
 
@@ -78,7 +77,6 @@ const viemChainFactory = (): ChainClientFactory => {
78
77
  const voter = new PubsubVoter({
79
78
  helia, // the host's Helia node; needs a gossipsub service at libp2p.services.pubsub + a blockstore
80
79
  chains: viemChainFactory(), // ({ chainId }) => viem PublicClient | undefined
81
- signer: mySigner, // optional; omit → read-only voter
82
80
  nameResolvers: [bsoResolver], // optional; verifies community-name claims (e.g. @bitsocial/bso-resolver)
83
81
  dataPath: "/path/to/data", // optional; persistent state: caches + checkpoint snapshots (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
84
82
  httpRouterUrls: [ // optional, SEEDERS ONLY (publicly reachable node): announce provider
@@ -87,9 +85,9 @@ const voter = new PubsubVoter({
87
85
  });
88
86
  ```
89
87
 
90
- 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.)
88
+ Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingFetchError` if the node lacks a usable pubsub service, blockstore, or libp2p fetch service (and `MissingPrivateKeyError` if `httpRouterUrls` is set but the node exposes no signing key, since provider records must be signed) — 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.)
91
89
 
92
- ### Read a tally reactively (no signer needed)
90
+ ### Read a tally reactively
93
91
 
94
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`:
95
93
 
@@ -176,31 +174,56 @@ A cold join **renders fast and refines**: checkpoint bundles are admitted after
176
174
 
177
175
  Repeated `createContest` calls with byte-identical criteria return the same `Contest` (engines are keyed by topic, the criteria CID).
178
176
 
179
- ### Publish or withdraw a vote (needs a signer)
177
+ ### Publish or withdraw a vote
180
178
 
181
- `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:
182
180
 
183
181
  ```ts
184
- const vote = await voter.createContestVote({ criteria, votes: [{ community: { publicKey: "12D3KooW..." }, vote: 1 }] });
185
- vote.on("publishingstatechange", (state) => console.log(state)); // stopped → signing → publishing → succeeded (or failed)
186
- 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
187
190
 
188
191
  // Withdraw (active): publish an empty ballot; it supersedes the prior vote under LWW.
189
- await (await voter.createContestVote({ criteria, votes: [] })).publish();
192
+ await (await voter.createContestVote({ criteria, votes: [], signer: mySigner })).publish();
190
193
  ```
191
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
+
192
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).
193
198
 
194
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).
195
200
 
196
- `publish()` on a voter built without a `signer` throws `ReadOnlyError` (and emits an `error`).
197
-
198
201
  #### Rejection feedback
199
202
 
200
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:
201
204
 
202
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.
203
- - **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
+ ```
204
227
 
205
228
  ```ts
206
229
  vote.on("error", (err) => {
@@ -211,7 +234,7 @@ await vote.publish(); // throws InvalidCommunityNameError if a carried name can'
211
234
 
212
235
  ### Republishing is the client's job
213
236
 
214
- 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:
215
238
 
216
239
  ```ts
217
240
  import { republishIntervalBuckets } from "@bitsocial/pubsub-voting";
@@ -245,7 +268,7 @@ There is no separate seeder API: a node that joins a topic (via `update()` or `p
245
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.)
246
269
 
247
270
  ```ts
248
- const voter = new PubsubVoter({ helia, chains, signer });
271
+ const voter = new PubsubVoter({ helia, chains });
249
272
  // … create + update contests, app runs …
250
273
  await voter.destroy(); // terminal: leave all topics, unregister the responder, forbid reuse
251
274
  ```
@@ -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
@@ -182,6 +207,47 @@ export interface Contest {
182
207
  checkEligibility(args: {
183
208
  address: string;
184
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;
185
251
  /**
186
252
  * Fired when incoming votes change the state; `tally` carries the freshly recomputed
187
253
  * ranking. Background check settlements fire it too: a cold join emits a first tally with
@@ -206,21 +272,34 @@ export interface ContestVote {
206
272
  readonly topic: string;
207
273
  /** The votes this ballot will sign and broadcast (empty array = a withdrawal). */
208
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;
209
282
  /** Where in the publish lifecycle this ballot is. */
210
283
  readonly publishingState: PublishingState;
211
284
  /** The signed bundle, once `publish()` has produced it (`undefined` before then). */
212
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;
213
293
  /**
214
294
  * Sign the votes into a bundle for the current bucket, add it to the local CRDT, and broadcast
215
295
  * it once as a live delta. Joins the topic first if needed. Resolves a {@link PublishOutcome}:
216
296
  * the `VotesBundle` (whose `blockNumber` the client uses to schedule its own refresh — see
217
297
  * {@link republishIntervalBuckets}) plus `recipientCount`, the number of peers gossipsub sent
218
298
  * the vote directly to. Emits `publishingstatechange` as it goes; throws (and emits `error`) on
219
- * failure: `ReadOnlyError` with no signer, and `InvalidCommunityNameError` when a vote's
220
- * carried `community.name` definitively does not resolve to its claimed `publicKey` (checked
221
- * BEFORE signing or joining — every verifier drops such a bundle silently, so it is refused
222
- * here instead of published into a network-wide silent drop; a resolver outage does not block
223
- * 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()`
224
303
  * again before it expires.
225
304
  */
226
305
  publish(): Promise<PublishOutcome>;
@@ -237,8 +316,6 @@ export interface ContestVote {
237
316
  }
238
317
  /** The factory: one set of injected dependencies, many contests. */
239
318
  export interface VoteClient {
240
- /** True when constructed without a signer: every contest is read-only. */
241
- readonly readOnly: boolean;
242
319
  /**
243
320
  * Create the reactive read view for one contest from its full criteria document. The document
244
321
  * is strictly validated (`CriteriaSchema` + the rule registry — an unimplemented rule throws
@@ -253,10 +330,16 @@ export interface VoteClient {
253
330
  * Create a publishable ballot for one contest, validated and addressed exactly like
254
331
  * `createContest`. Each call returns a fresh `ContestVote` over the shared per-topic engine;
255
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.
256
338
  */
257
339
  createContestVote(args: {
258
340
  criteria: Criteria;
259
341
  votes: Vote[];
342
+ signer: VoteSigner;
260
343
  }): Promise<ContestVote>;
261
344
  /**
262
345
  * Leave every topic this client joined, resetting each read view so it can `update()` again.
@@ -297,8 +380,6 @@ export interface PubsubVoterOptions {
297
380
  * See `ChainClientFactory` (src/chain/types.ts) for the full contract.
298
381
  */
299
382
  chains: ChainClientFactory;
300
- /** Identity. Omit for a read-only voter (renders tallies, cannot publish). */
301
- signer?: VoteSigner;
302
383
  /** Rule overrides that shadow built-ins by `type` (a flat `type -> rule` map). */
303
384
  rules?: RuleRegistry;
304
385
  /**
@@ -379,13 +460,13 @@ export declare function makeHeadReader(): (chain: ChainClient) => Promise<bigint
379
460
  export declare class PubsubVoter implements VoteClient {
380
461
  #private;
381
462
  constructor(options: PubsubVoterOptions);
382
- get readOnly(): boolean;
383
463
  createContest(args: {
384
464
  criteria: Criteria;
385
465
  }): Promise<Contest>;
386
466
  createContestVote(args: {
387
467
  criteria: Criteria;
388
468
  votes: Vote[];
469
+ signer: VoteSigner;
389
470
  }): Promise<ContestVote>;
390
471
  stop(): Promise<void>;
391
472
  destroy(): Promise<void>;
@@ -31,7 +31,7 @@ import { CID } from "multiformats/cid";
31
31
  import { makeTally } from "../tally/tally.js";
32
32
  import { ballotTypedData } from "../signer/eip712.js";
33
33
  import { criteriaCid, TOPIC_PREFIX } from "../topic.js";
34
- import { InvalidCommunityNameError, MissingChainClientError, ReadOnlyError, UnknownRuleError, VoteEvictedError, VoterDestroyedError } from "../errors.js";
34
+ import { InvalidCommunityNameError, MissingChainClientError, UnknownRuleError, VoteEvictedError, VoterDestroyedError } from "../errors.js";
35
35
  /**
36
36
  * The recommended cadence, in buckets, at which a client should re-publish a live vote to keep
37
37
  * it alive: half its expiry window, rounded up. A bundle is valid for `voteExpiryBuckets` after
@@ -44,7 +44,7 @@ import { InvalidCommunityNameError, MissingChainClientError, ReadOnlyError, Unkn
44
44
  * `blockNumber` on a published `VotesBundle`, and `criteria.voteExpiryBuckets` /
45
45
  * `criteria.blocksPerBucket` are what a client uses to schedule its own refreshes: a vote sampled
46
46
  * at bucket `b` expires once the current bucket exceeds `b + voteExpiryBuckets`; refresh by
47
- * calling `createContestVote({ criteria, votes }).publish()` again before then.
47
+ * calling `createContestVote({ criteria, votes, signer }).publish()` again before then.
48
48
  */
49
49
  export function republishIntervalBuckets(criteria) {
50
50
  return Math.ceil(criteria.voteExpiryBuckets / 2);
@@ -165,6 +165,14 @@ const CHASE_SESSION_PROVIDER_HEADROOM = 1;
165
165
  * cannot grow memory.
166
166
  */
167
167
  const PEER_ROOTS_MAX = 256;
168
+ /**
169
+ * How many distinct chased roots we remember the contents of, for peer-checkpoint attribution
170
+ * ({@link ContestEngine.checkpointPeersFor}). Bounded for the same reason as {@link PEER_ROOTS_MAX}:
171
+ * a busy topic mints a new root on every admitted vote, so an unbounded index would grow with
172
+ * traffic forever. Forgetting a root only costs attribution for peers still advertising it — the
173
+ * next chase of a newer root re-establishes it.
174
+ */
175
+ const DECODED_ROOTS_MAX = 64;
168
176
  /**
169
177
  * How long a gating-chain head read stays fresh (ms) for the gate's freshness guard. Steady-
170
178
  * state votes cost no read (they resolve against the cached bucket); only a look-ahead bundle
@@ -530,7 +538,6 @@ function hexToBytes(hex) {
530
538
  class ContestEngine {
531
539
  criteria;
532
540
  topic;
533
- readOnly;
534
541
  #deps;
535
542
  #criteriaCid;
536
543
  /** The contest's chain id (`criteria.bucketChainId`), bound into every ballot signature. */
@@ -598,7 +605,6 @@ class ContestEngine {
598
605
  constructor(criteria, topic, criteriaCidBytes, deps) {
599
606
  this.criteria = criteria;
600
607
  this.topic = topic;
601
- this.readOnly = deps.signer === undefined;
602
608
  this.#deps = deps;
603
609
  this.#criteriaCid = criteriaCidBytes;
604
610
  // The contest's ONE chain (`bucketChainId`): it fixes the ballot's chainId, the blocks the
@@ -721,6 +727,45 @@ class ContestEngine {
721
727
  #ownPublishes = new Map();
722
728
  /** Per-own-CID eviction callbacks: the publishing `ContestVote` registers one at sign time. */
723
729
  #ownEvictionCbs = new Map();
730
+ /**
731
+ * Per-own-CID verification callbacks — the positive counterpart of {@link #ownEvictionCbs},
732
+ * registered by the publishing `ContestVote` at sign time and fired once every deferred check
733
+ * on that bundle has settled clean.
734
+ */
735
+ #ownVerifiedCbs = new Map();
736
+ /**
737
+ * Per-own-CID first-peer-checkpoint callbacks. Fires once — the state it drives has no
738
+ * degrees, and a second peer holding the vote is not a new fact about the publish.
739
+ */
740
+ #ownCheckpointCbs = new Map();
741
+ /**
742
+ * Every bundle CID this wallet published that is still in the working set. Unlike
743
+ * {@link #ownPublishes} — which exists only to report an eviction and is dropped the moment
744
+ * the checks settle — this outlives verification, because peer-checkpoint attribution keeps
745
+ * mattering afterwards: a bundle stays live for `voteExpiryBuckets` and peers go on
746
+ * re-serving it, so "who else kept my vote" is a longer-lived question than "is it valid".
747
+ * Dropped on eviction and on expiry prune.
748
+ */
749
+ #ownCids = new Set();
750
+ /**
751
+ * Roots we decoded → which of OUR CIDs that checkpoint contained. Kept so a peer that
752
+ * advertises an ALREADY-decoded root is attributed without re-chasing it (the common case:
753
+ * many peers converge on one root). Bounded by {@link DECODED_ROOTS_MAX}, oldest evicted first.
754
+ */
755
+ #decodedRoots = new Map();
756
+ /**
757
+ * Own CID → the peers seen advertising a checkpoint that contained it. Bounded twice over:
758
+ * one entry per own bundle (one per wallet per contest), and the peers can only ever come
759
+ * from {@link #peerRoots}, itself capped at {@link PEER_ROOTS_MAX}.
760
+ */
761
+ #ownCheckpointPeers = new Map();
762
+ /**
763
+ * Cap on {@link #ownCids}. A wallet holds one live bundle per contest and a re-publish
764
+ * supersedes it, so real use sits far below this; the cap exists because
765
+ * {@link ContestEngine.trackOwnBundle} lets a caller add CIDs, and no public entry point
766
+ * should be able to grow a map without bound.
767
+ */
768
+ static #OWN_CIDS_MAX = 64;
724
769
  /** Does any vote in the bundle carry a `community.name` claim (needing resolution)? */
725
770
  #carriesName(bundle) {
726
771
  return bundle.votes.some((v) => v.community.name !== undefined);
@@ -755,9 +800,13 @@ class ContestEngine {
755
800
  return; // evicted or pruned while its check was in flight
756
801
  checks[key] = true;
757
802
  // A fully settled own publish can no longer be evicted — its verdict is terminal — so
758
- // its eviction-reporting entries are done (see #ownPublishes).
759
- if (this.#isFullyVerified(cid))
760
- this.#dropOwnTracking(cid.toString());
803
+ // its eviction-reporting entries are done (see #ownPublishes). Tell the publishing
804
+ // ContestVote first: this is the positive verdict it has no other way to hear.
805
+ if (this.#isFullyVerified(cid)) {
806
+ const key = cid.toString();
807
+ this.#ownVerifiedCbs.get(key)?.(cid);
808
+ this.#dropOwnTracking(key);
809
+ }
761
810
  this.#onStateChanged();
762
811
  }
763
812
  /**
@@ -771,11 +820,13 @@ class ContestEngine {
771
820
  this.#crdt.remove(cid);
772
821
  const key = cid.toString();
773
822
  this.#checks.delete(key);
823
+ this.#ownCids.delete(key);
824
+ this.#ownCheckpointPeers.delete(key);
774
825
  const own = this.#ownPublishes.get(key);
775
826
  if (own) {
776
827
  const error = new VoteEvictedError(own, verdict);
777
828
  const notifyVote = this.#ownEvictionCbs.get(key);
778
- this.#dropOwnTracking(key);
829
+ this.#forgetOwnBundle(key);
779
830
  notifyVote?.(error);
780
831
  this.#emitError(error);
781
832
  }
@@ -785,6 +836,19 @@ class ContestEngine {
785
836
  #dropOwnTracking(key) {
786
837
  this.#ownPublishes.delete(key);
787
838
  this.#ownEvictionCbs.delete(key);
839
+ this.#ownVerifiedCbs.delete(key);
840
+ }
841
+ /**
842
+ * Forget an own bundle entirely — its verdict-reporting entries AND its peer-checkpoint
843
+ * attribution. Only for a bundle that has LEFT the working set (evicted or expired):
844
+ * settlement alone must not reach this, because attribution goes on accruing for a bundle
845
+ * that is verified and live (see {@link #ownCids}).
846
+ */
847
+ #forgetOwnBundle(key) {
848
+ this.#dropOwnTracking(key);
849
+ this.#ownCheckpointCbs.delete(key);
850
+ this.#ownCids.delete(key);
851
+ this.#ownCheckpointPeers.delete(key);
788
852
  }
789
853
  #emitError(error) {
790
854
  for (const cb of [...this.#errorListeners])
@@ -944,7 +1008,7 @@ class ContestEngine {
944
1008
  for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
945
1009
  const key = removed.toString();
946
1010
  this.#checks.delete(key);
947
- this.#dropOwnTracking(key); // expiry is decay, not an eviction — no error
1011
+ this.#forgetOwnBundle(key); // expiry is decay, not an eviction — no error
948
1012
  }
949
1013
  }
950
1014
  return this.#tally.compute();
@@ -1079,6 +1143,9 @@ class ContestEngine {
1079
1143
  this.#markStateChanged();
1080
1144
  },
1081
1145
  deferVerify: (entries) => this.#background.enqueue(entries),
1146
+ // Every CID the checkpoint referenced, admitted or skipped — the skipped ones are
1147
+ // what tier-3 attribution is made of (see #noteCheckpointContents).
1148
+ onCheckpointContents: (root, cids) => this.#noteCheckpointContents(root, cids),
1082
1149
  onMerged: () => this.#onStateChanged(),
1083
1150
  limit: (fn) => chaseLimit(fn),
1084
1151
  timeoutMs: CHASE_TIMEOUT_MS
@@ -1343,15 +1410,13 @@ class ContestEngine {
1343
1410
  /**
1344
1411
  * Sign the votes into a bundle for the current bucket boundary block (the block every verifier
1345
1412
  * reads at), add it to the CRDT, and return the bundle plus its encoded block bytes for
1346
- * broadcast. Throws `ReadOnlyError` with no signer. `namesSettled` carries the
1347
- * {@link preflightNames} outcome (default false: name checks still owed to the background
1348
- * verifier); `onEvicted` is told if a deferred check later evicts THIS bundle — registered
1349
- * here, before the background verifier can possibly settle, so the report cannot be missed.
1413
+ * broadcast with the wallet the caller hands it (identity is the ballot's, see
1414
+ * {@link VoteClient.createContestVote}). `namesSettled` carries the {@link preflightNames}
1415
+ * outcome (default false: name checks still owed to the background verifier); `onEvicted` is
1416
+ * told if a deferred check later evicts THIS bundle registered here, before the background
1417
+ * verifier can possibly settle, so the report cannot be missed.
1350
1418
  */
1351
- async signVote(votes, opts = {}) {
1352
- const signer = this.#deps.signer;
1353
- if (signer === undefined)
1354
- throw new ReadOnlyError();
1419
+ async signVote(votes, signer, opts = {}) {
1355
1420
  const head = await this.#ruleChain.getBlockNumber();
1356
1421
  const bucket = this.#bucketMath.bucketForBlock(Number(head));
1357
1422
  this.#currentBucketCache = bucket;
@@ -1368,11 +1433,16 @@ class ContestEngine {
1368
1433
  // preflight already resolved is recorded settled, so it renders verified immediately.
1369
1434
  this.#recordChecks(cid, bundle, false, opts.namesSettled ?? false);
1370
1435
  this.#ownPublishes.set(cid.toString(), bundle);
1436
+ this.#ownCids.add(cid.toString());
1371
1437
  if (opts.onEvicted)
1372
1438
  this.#ownEvictionCbs.set(cid.toString(), opts.onEvicted);
1439
+ if (opts.onVerified)
1440
+ this.#ownVerifiedCbs.set(cid.toString(), opts.onVerified);
1441
+ if (opts.onCheckpointedByPeer)
1442
+ this.#ownCheckpointCbs.set(cid.toString(), opts.onCheckpointedByPeer);
1373
1443
  this.#background.enqueue([{ cid, bundle }]);
1374
1444
  this.#onStateChanged();
1375
- return { bundle, encoded: encodeBundle(bundle) };
1445
+ return { bundle, encoded: encodeBundle(bundle), cid };
1376
1446
  }
1377
1447
  /**
1378
1448
  * Broadcast an encoded bundle inline as a live delta (this wallet's own delta, never the set).
@@ -1603,6 +1673,12 @@ class ContestEngine {
1603
1673
  const own = await this.rootRecord();
1604
1674
  if (own.root.equals(record.root)) {
1605
1675
  this.#heardMatchingRoot = true;
1676
+ // Their checkpoint is ours byte for byte, so it demonstrably contains every bundle
1677
+ // ours does — including our own. Nothing is chased here (there is no divergence to
1678
+ // chase), so without this the ONE case tier-3 attribution most wants to catch — a
1679
+ // healthy topic where we and the seeder have converged — would be the one case it
1680
+ // never saw.
1681
+ this.#indexRootContents(record.root, this.#ownVerifiedCids());
1606
1682
  return;
1607
1683
  }
1608
1684
  this.#chaser?.chase(record.root, undefined, this.#sessionProvidersFor(record.root));
@@ -1621,7 +1697,112 @@ class ContestEngine {
1621
1697
  if (oldest !== undefined)
1622
1698
  this.#peerRoots.delete(oldest);
1623
1699
  }
1624
- this.#peerRoots.set(peerId, root.toString());
1700
+ const key = root.toString();
1701
+ this.#peerRoots.set(peerId, key);
1702
+ // Many peers converge on one root, and most of them advertise it AFTER we chased it —
1703
+ // so attribution cannot wait for a decode that already happened. #decodedRoots is what
1704
+ // makes that retroactive.
1705
+ this.#attributeRoot(peerId, key);
1706
+ }
1707
+ /**
1708
+ * Record which of OUR bundles a decoded checkpoint contained, and credit every peer already
1709
+ * known to advertise that root. Called once per successful chase decode, with every bundle
1710
+ * CID the checkpoint referenced — INCLUDING ones we already hold, which is the whole point:
1711
+ * our own bundle is always already held, so the chase skips admitting it and would otherwise
1712
+ * never mention it.
1713
+ */
1714
+ #noteCheckpointContents(root, cids) {
1715
+ const mine = new Set();
1716
+ for (const cid of cids) {
1717
+ const key = cid.toString();
1718
+ if (this.#ownCids.has(key))
1719
+ mine.add(key);
1720
+ }
1721
+ this.#indexRootContents(root, mine);
1722
+ }
1723
+ /**
1724
+ * Our own bundles that are in our OWN checkpoint right now — i.e. fully verified ones. A
1725
+ * pending bundle is deliberately excluded: the encoder does not serve it, so a peer holding
1726
+ * our root is not thereby holding it.
1727
+ */
1728
+ #ownVerifiedCids() {
1729
+ const mine = new Set();
1730
+ for (const key of this.#ownCids) {
1731
+ if (this.#isFullyVerified(CID.parse(key)))
1732
+ mine.add(key);
1733
+ }
1734
+ return mine;
1735
+ }
1736
+ /** Index `mine` under `root` (bounded, LRU) and credit every peer already at that root. */
1737
+ #indexRootContents(root, mine) {
1738
+ if (mine.size === 0)
1739
+ return; // nothing of ours in it — not worth an index entry
1740
+ const rootKey = root.toString();
1741
+ if (this.#decodedRoots.has(rootKey))
1742
+ this.#decodedRoots.delete(rootKey); // refresh LRU slot
1743
+ else if (this.#decodedRoots.size >= DECODED_ROOTS_MAX) {
1744
+ const oldest = this.#decodedRoots.keys().next().value;
1745
+ if (oldest !== undefined)
1746
+ this.#decodedRoots.delete(oldest);
1747
+ }
1748
+ this.#decodedRoots.set(rootKey, mine);
1749
+ let credited = false;
1750
+ for (const [peerId, peerRoot] of this.#peerRoots) {
1751
+ if (peerRoot === rootKey)
1752
+ credited = this.#creditPeer(peerId, mine) || credited;
1753
+ }
1754
+ if (credited)
1755
+ this.#onStateChanged();
1756
+ }
1757
+ /** Credit `peerId` with every own CID in a root it advertises, if we decoded that root. */
1758
+ #attributeRoot(peerId, rootKey) {
1759
+ const mine = this.#decodedRoots.get(rootKey);
1760
+ if (mine !== undefined && this.#creditPeer(peerId, mine))
1761
+ this.#onStateChanged();
1762
+ }
1763
+ /** Add `peerId` to each own CID's attribution set; true if anything was new. */
1764
+ #creditPeer(peerId, ownCids) {
1765
+ let added = false;
1766
+ for (const key of ownCids) {
1767
+ if (!this.#ownCids.has(key))
1768
+ continue; // evicted or expired since the decode
1769
+ let peers = this.#ownCheckpointPeers.get(key);
1770
+ if (peers === undefined) {
1771
+ peers = new Set();
1772
+ this.#ownCheckpointPeers.set(key, peers);
1773
+ }
1774
+ if (!peers.has(peerId)) {
1775
+ const first = peers.size === 0;
1776
+ peers.add(peerId);
1777
+ added = true;
1778
+ if (first) {
1779
+ this.#ownCheckpointCbs.get(key)?.(CID.parse(key));
1780
+ this.#ownCheckpointCbs.delete(key);
1781
+ }
1782
+ }
1783
+ }
1784
+ return added;
1785
+ }
1786
+ /** {@link Contest.trackOwnBundle}. */
1787
+ trackOwnBundle(cid) {
1788
+ const key = cid.toString();
1789
+ if (this.#ownCids.has(key))
1790
+ return;
1791
+ if (this.#ownCids.size >= ContestEngine.#OWN_CIDS_MAX) {
1792
+ const oldest = this.#ownCids.values().next().value;
1793
+ if (oldest !== undefined)
1794
+ this.#forgetOwnBundle(oldest);
1795
+ }
1796
+ this.#ownCids.add(key);
1797
+ }
1798
+ /** {@link Contest.checksFor}. */
1799
+ checksFor(cid) {
1800
+ const checks = this.#checks.get(cid.toString());
1801
+ return checks === undefined ? undefined : { ...checks };
1802
+ }
1803
+ /** {@link Contest.checkpointPeersFor}. */
1804
+ checkpointPeersFor(cid) {
1805
+ return [...(this.#ownCheckpointPeers.get(cid.toString()) ?? [])];
1625
1806
  }
1626
1807
  /**
1627
1808
  * The session seeds for chasing `root`: every still-connected peer whose last advertised
@@ -1707,6 +1888,15 @@ class ContestView {
1707
1888
  get tally() {
1708
1889
  return this.#engine.cachedTally;
1709
1890
  }
1891
+ checksFor(cid) {
1892
+ return this.#engine.checksFor(cid);
1893
+ }
1894
+ checkpointPeersFor(cid) {
1895
+ return this.#engine.checkpointPeersFor(cid);
1896
+ }
1897
+ trackOwnBundle(cid) {
1898
+ this.#engine.trackOwnBundle(cid);
1899
+ }
1710
1900
  async update() {
1711
1901
  if (this.#subscribed)
1712
1902
  return;
@@ -1754,22 +1944,62 @@ class ContestView {
1754
1944
  class ContestVotePublication {
1755
1945
  contestId;
1756
1946
  votes;
1947
+ signer;
1757
1948
  #engine;
1758
1949
  #stateCbs = [];
1759
1950
  #errorCbs = [];
1760
1951
  #state = "stopped";
1761
1952
  #bundle;
1953
+ #cid;
1954
+ /**
1955
+ * Which `publish()` call owns the state machine. Incremented synchronously at the top of
1956
+ * `publish()`, BEFORE any await, and captured by every callback that attempt registers.
1957
+ *
1958
+ * A re-publish in a later window signs new bytes, so the previous attempt's bundle keeps its
1959
+ * own CID, stays live in the CRDT (a superseded bundle outlives its superseder's deferred
1960
+ * checks) and can still be verified, served by a peer, or evicted — long after a newer
1961
+ * attempt owns this object. Matching on the CID cannot separate the two: `#cid` still holds
1962
+ * the PREVIOUS attempt's value until the new `signVote()` resolves, so an old verdict landing
1963
+ * in that window passes a CID check and moves the wrong attempt. The token is established
1964
+ * before that window opens, so it does not. It also decouples the check from `#cid` entirely,
1965
+ * which is what lets a verdict that settles BEFORE `#cid` is assigned still be delivered
1966
+ * (the engine delivers each callback once and then drops it — a settlement ignored here is
1967
+ * lost for good).
1968
+ */
1969
+ #attempt = 0;
1762
1970
  /**
1763
1971
  * True once the background verifier evicted the current publish's bundle. The eviction can
1764
1972
  * land WHILE `publish()` is still broadcasting (the deferred checks run concurrently), and
1765
1973
  * its `"failed"` is terminal for this attempt — the in-flight publish must not stomp it
1766
- * with `"publishing"`/`"succeeded"`. Reset by the next `publish()` call.
1974
+ * with `"publishing"`/`"published"`. Reset by the next `publish()` call.
1767
1975
  */
1768
1976
  #evicted = false;
1769
- constructor(engine, votes) {
1977
+ /** Is this callback's attempt still the one that owns the object? (see {@link #attempt}) */
1978
+ #isCurrent(attempt) {
1979
+ return attempt === this.#attempt;
1980
+ }
1981
+ /**
1982
+ * Advance to a settled state, unless this attempt is already finished or further along.
1983
+ * Two orderings have to be tolerated, not just one: the deferred checks race the broadcast,
1984
+ * so a verdict can land before `publish()` resolves, and a peer can serve our bundle back to
1985
+ * us before our OWN gate read returns — peer evidence is strictly stronger (a peer
1986
+ * checkpoints only fully verified bundles), so it must never be overwritten by the weaker
1987
+ * local one arriving late.
1988
+ */
1989
+ #settle(attempt, state) {
1990
+ if (!this.#isCurrent(attempt))
1991
+ return;
1992
+ if (this.#evicted || this.#state === "failed" || this.#state === "verified-by-peer")
1993
+ return;
1994
+ if (state === "verified-locally" && this.#state === "verified-locally")
1995
+ return;
1996
+ this.#setState(state);
1997
+ }
1998
+ constructor(engine, votes, signer) {
1770
1999
  this.#engine = engine;
1771
2000
  this.contestId = engine.criteria.contestId;
1772
2001
  this.votes = votes;
2002
+ this.signer = signer;
1773
2003
  }
1774
2004
  get topic() {
1775
2005
  return this.#engine.topic;
@@ -1780,6 +2010,9 @@ class ContestVotePublication {
1780
2010
  get bundle() {
1781
2011
  return this.#bundle;
1782
2012
  }
2013
+ get checks() {
2014
+ return this.#cid === undefined ? undefined : this.#engine.checksFor(this.#cid);
2015
+ }
1783
2016
  #setState(state) {
1784
2017
  this.#state = state;
1785
2018
  for (const cb of [...this.#stateCbs])
@@ -1791,12 +2024,11 @@ class ContestVotePublication {
1791
2024
  cb(error);
1792
2025
  }
1793
2026
  async publish() {
1794
- // Fail before joining a read-only voter needlessly to the topic.
1795
- if (this.#engine.readOnly) {
1796
- const error = new ReadOnlyError();
1797
- this.#fail(error);
1798
- throw error;
1799
- }
2027
+ // Before the first await: every callback below is bound to THIS attempt, and the
2028
+ // previous attempt's callbacks stop being able to move this object the moment the
2029
+ // counter ticks even though its bundle is still live and still verifiable.
2030
+ const attempt = ++this.#attempt;
2031
+ this.#evicted = false;
1800
2032
  try {
1801
2033
  // Name preflight, also before joining: a vote whose carried community name
1802
2034
  // definitively fails to resolve to its claimed key would be silently dropped by
@@ -1805,30 +2037,48 @@ class ContestVotePublication {
1805
2037
  // the background verifier owns the deferred check).
1806
2038
  const namesSettled = await this.#engine.preflightNames(this.votes);
1807
2039
  await this.#engine.join();
1808
- this.#evicted = false;
1809
2040
  this.#setState("signing");
1810
- const { bundle, encoded } = await this.#engine.signVote([...this.votes], {
2041
+ const { bundle, encoded, cid } = await this.#engine.signVote([...this.votes], this.signer, {
1811
2042
  namesSettled,
2043
+ // The positive counterparts of `onEvicted`: our own deferred checks settling
2044
+ // clean, then a peer serving the bundle back to us in its checkpoint. Both
2045
+ // normally land after publish() has resolved.
2046
+ onVerified: () => this.#settle(attempt, "verified-locally"),
2047
+ onCheckpointedByPeer: () => this.#settle(attempt, "verified-by-peer"),
1812
2048
  // The one rejection a publisher can hear about (peers drop silently): our own
1813
2049
  // node's deferred checks evicting this bundle. Usually post hoc — publish() has
1814
2050
  // already resolved — so it surfaces as `error` + `publishingState: "failed"`.
1815
2051
  onEvicted: (error) => {
2052
+ // Attempt-scoped like the positive verdicts: a superseded bundle is kept
2053
+ // alive while its superseder's checks are pending, so an eviction of the
2054
+ // PREVIOUS attempt's bytes must not fail the attempt that replaced it.
2055
+ if (!this.#isCurrent(attempt))
2056
+ return;
1816
2057
  this.#evicted = true;
1817
2058
  this.#fail(error);
1818
2059
  }
1819
2060
  });
1820
2061
  this.#bundle = bundle;
1821
- if (!this.#evicted)
2062
+ this.#cid = cid;
2063
+ // Only from "signing": a deferred check can settle before this line runs (a cached
2064
+ // verdict needs no round trip), and "publishing" would walk that verdict backwards.
2065
+ if (this.#state === "signing")
1822
2066
  this.#setState("publishing");
1823
2067
  const { recipientCount } = await this.#engine.broadcastBundle(encoded);
1824
2068
  // An eviction that landed mid-broadcast already failed this attempt; the outcome
1825
- // still resolves (the bundle DID hit the wire) but the state stays "failed".
1826
- if (!this.#evicted)
1827
- this.#setState("succeeded");
1828
- return { bundle, recipientCount };
2069
+ // still resolves (the bundle DID hit the wire) but the state stays "failed". A
2070
+ // check that SETTLED mid-broadcast is not stomped either: "published" describes
2071
+ // the broadcast, and a verdict already in hand is further along than that.
2072
+ if (!this.#evicted && this.#state === "publishing")
2073
+ this.#setState("published");
2074
+ return { bundle, recipientCount, cid };
1829
2075
  }
1830
2076
  catch (error) {
1831
- this.#fail(error);
2077
+ // Same scoping: two overlapping `publish()` calls are a misuse, but the loser's
2078
+ // rejection belongs to ITS caller (it is rethrown), not to the state machine a
2079
+ // later attempt now owns.
2080
+ if (this.#isCurrent(attempt))
2081
+ this.#fail(error);
1832
2082
  throw error;
1833
2083
  }
1834
2084
  }
@@ -1894,7 +2144,6 @@ export class PubsubVoter {
1894
2144
  // the background verifier) merge into shared multicall3 round trips under one
1895
2145
  // per-client in-flight budget — see src/chain/coalescer.ts.
1896
2146
  chains: coalescingChainFactory(options.chains),
1897
- signer: options.signer,
1898
2147
  registry: resolveRegistry(options.rules),
1899
2148
  nameResolvers: options.nameResolvers ?? [],
1900
2149
  onTopicJoined: this.#onTopicJoined,
@@ -1974,9 +2223,6 @@ export class PubsubVoter {
1974
2223
  // Contests can share CIDs (e.g. two vote-less contests share the empty-checkpoint root).
1975
2224
  return [...new Set(keys)];
1976
2225
  };
1977
- get readOnly() {
1978
- return this.#deps.signer === undefined;
1979
- }
1980
2226
  /** Guard the create paths after {@link destroy}: a destroyed voter is terminal. */
1981
2227
  #assertLive() {
1982
2228
  if (this.#destroyed)
@@ -1995,7 +2241,7 @@ export class PubsubVoter {
1995
2241
  async createContestVote(args) {
1996
2242
  this.#assertLive();
1997
2243
  const engine = await this.#engineFor(this.#validateCriteria(args.criteria));
1998
- return new ContestVotePublication(engine, args.votes);
2244
+ return new ContestVotePublication(engine, args.votes, args.signer);
1999
2245
  }
2000
2246
  /**
2001
2247
  * Strictly validate one criteria document at the create seam: `CriteriaSchema` (shape,
package/dist/errors.d.ts CHANGED
@@ -68,6 +68,17 @@ export declare class MissingBlockstoreError extends Error {
68
68
  export declare class MissingFetchError extends Error {
69
69
  constructor();
70
70
  }
71
+ /**
72
+ * Thrown at construction when `PubsubVoterOptions.httpRouterUrls` is set but the injected Helia
73
+ * node's libp2p exposes no signing key (neither `libp2p.privateKey` nor `libp2p.components.privateKey`).
74
+ * Delegated-routing providers are announced as IPIP-0526 **signed** records — the production router
75
+ * verifies by default and rejects the whole PUT otherwise — so a node that cannot sign cannot
76
+ * announce, and announcing unsigned would silently leave the seeder undiscoverable on every
77
+ * verifying router. See DESIGN.md "Provider-record announces".
78
+ */
79
+ export declare class MissingPrivateKeyError extends Error {
80
+ constructor();
81
+ }
71
82
  /**
72
83
  * Thrown once a voter has been `destroy()`ed and something tries to keep using it. Unlike `stop()`
73
84
  * (which leaves every topic but keeps the client reusable), `destroy()` is terminal: every contest
@@ -87,10 +98,6 @@ export declare class DuplicateContestIdError extends Error {
87
98
  readonly contestId: string;
88
99
  constructor(contestId: string);
89
100
  }
90
- /** Thrown when a publish (vote/withdraw) is attempted on a voter constructed without a signer. */
91
- export declare class ReadOnlyError extends Error {
92
- constructor();
93
- }
94
101
  /**
95
102
  * Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
96
103
  * fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
package/dist/errors.js CHANGED
@@ -103,6 +103,25 @@ export class MissingFetchError extends Error {
103
103
  this.name = "MissingFetchError";
104
104
  }
105
105
  }
106
+ /**
107
+ * Thrown at construction when `PubsubVoterOptions.httpRouterUrls` is set but the injected Helia
108
+ * node's libp2p exposes no signing key (neither `libp2p.privateKey` nor `libp2p.components.privateKey`).
109
+ * Delegated-routing providers are announced as IPIP-0526 **signed** records — the production router
110
+ * verifies by default and rejects the whole PUT otherwise — so a node that cannot sign cannot
111
+ * announce, and announcing unsigned would silently leave the seeder undiscoverable on every
112
+ * verifying router. See DESIGN.md "Provider-record announces".
113
+ */
114
+ export class MissingPrivateKeyError extends Error {
115
+ constructor() {
116
+ super("The injected Helia node's libp2p exposes no private key (`libp2p.privateKey` / " +
117
+ "`libp2p.components.privateKey`), so provider records cannot be signed. Delegated " +
118
+ "Routing V1 routers verify the IPIP-0526 signature and reject unsigned records, so " +
119
+ "`httpRouterUrls` needs a node that can sign for its own peer id. Pass a real libp2p " +
120
+ "node, or drop `httpRouterUrls` to stop announcing. See DESIGN.md " +
121
+ "\"Provider-record announces\".");
122
+ this.name = "MissingPrivateKeyError";
123
+ }
124
+ }
106
125
  /**
107
126
  * Thrown once a voter has been `destroy()`ed and something tries to keep using it. Unlike `stop()`
108
127
  * (which leaves every topic but keeps the client reusable), `destroy()` is terminal: every contest
@@ -133,14 +152,6 @@ export class DuplicateContestIdError extends Error {
133
152
  this.name = "DuplicateContestIdError";
134
153
  }
135
154
  }
136
- /** Thrown when a publish (vote/withdraw) is attempted on a voter constructed without a signer. */
137
- export class ReadOnlyError extends Error {
138
- constructor() {
139
- super("This voter is read-only: it was constructed without a `signer`. " +
140
- "Provide a VoteSigner to publish or withdraw votes; reading tallies needs no signer.");
141
- this.name = "ReadOnlyError";
142
- }
143
- }
144
155
  /**
145
156
  * Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
146
157
  * fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
@@ -1,13 +1,15 @@
1
1
  import type { Announcer, AnnouncerOptions } from "./types.js";
2
2
  /**
3
3
  * The Node provider-record announcer (see types.ts for the seam rationale, and DESIGN.md
4
- * "Deferred pkc-js work", provider-record announces): one unsigned `PUT /routing/v1/providers`
4
+ * "Deferred pkc-js work", provider-record announces): one **signed** `PUT /routing/v1/providers`
5
5
  * per configured router per tick, kubo's body shape —
6
- * `{ Providers: [{ Schema: "peer", Payload: { ID, Addrs, Keys } }] }` — with `Keys` batched
7
- * across ALL joined contests. Unsigned is correct against the production router
8
- * (pkc-http-router reads only `Payload.{ID, Addrs, Keys, AdvisoryTTL}`; no signature field
9
- * exists), and its anti-spoofing keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's
10
- * source IP which a seeder announcing its own addresses passes naturally.
6
+ * `{ Providers: [{ Schema: "peer", Signature, Payload: { ID, Addrs, Keys, Timestamp } }] }` —
7
+ * with `Keys` batched across ALL joined contests. The signature is IPIP-0526, built by
8
+ * {@link signedProvidersBody} over the exact payload bytes put on the wire; the production router
9
+ * verifies by default and answers 403 for the whole request otherwise, so an unsigned record made
10
+ * the announcing node absent from every verifying router (issue #38). Its address anti-spoofing
11
+ * keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's source IP — which a seeder
12
+ * announcing its own addresses passes naturally.
11
13
  *
12
14
  * Addresses: the announceable set is `getMultiaddrs()` filtered to public/DNS addrs plus
13
15
  * exactly-unspecified addrs (`0.0.0.0`/`::`), which the production router rewrites to the PUT's
@@ -1,12 +1,15 @@
1
+ import { requireAnnounceSigner, signedProvidersBody } from "./record.js";
1
2
  /**
2
3
  * The Node provider-record announcer (see types.ts for the seam rationale, and DESIGN.md
3
- * "Deferred pkc-js work", provider-record announces): one unsigned `PUT /routing/v1/providers`
4
+ * "Deferred pkc-js work", provider-record announces): one **signed** `PUT /routing/v1/providers`
4
5
  * per configured router per tick, kubo's body shape —
5
- * `{ Providers: [{ Schema: "peer", Payload: { ID, Addrs, Keys } }] }` — with `Keys` batched
6
- * across ALL joined contests. Unsigned is correct against the production router
7
- * (pkc-http-router reads only `Payload.{ID, Addrs, Keys, AdvisoryTTL}`; no signature field
8
- * exists), and its anti-spoofing keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's
9
- * source IP which a seeder announcing its own addresses passes naturally.
6
+ * `{ Providers: [{ Schema: "peer", Signature, Payload: { ID, Addrs, Keys, Timestamp } }] }` —
7
+ * with `Keys` batched across ALL joined contests. The signature is IPIP-0526, built by
8
+ * {@link signedProvidersBody} over the exact payload bytes put on the wire; the production router
9
+ * verifies by default and answers 403 for the whole request otherwise, so an unsigned record made
10
+ * the announcing node absent from every verifying router (issue #38). Its address anti-spoofing
11
+ * keeps `/ip4`/`/ip6` addrs only when the IP matches the PUT's source IP — which a seeder
12
+ * announcing its own addresses passes naturally.
10
13
  *
11
14
  * Addresses: the announceable set is `getMultiaddrs()` filtered to public/DNS addrs plus
12
15
  * exactly-unspecified addrs (`0.0.0.0`/`::`), which the production router rewrites to the PUT's
@@ -113,7 +116,7 @@ export function sentinelAddrs(addrs) {
113
116
  }
114
117
  return [...sentinels];
115
118
  }
116
- /** One unsigned kubo-shape provider PUT; throws on timeout or a non-2xx answer. */
119
+ /** One signed kubo-shape provider PUT; throws on timeout or a non-2xx answer. */
117
120
  async function putProviders(baseUrl, body, timeoutMs) {
118
121
  const endpoint = `${baseUrl.replace(/\/+$/, "")}/routing/v1/providers`;
119
122
  const res = await fetch(endpoint, {
@@ -132,6 +135,10 @@ export function makeAnnouncer(options) {
132
135
  const intervalMs = options.intervalMs ?? ANNOUNCE_INTERVAL_MS;
133
136
  const debounceMs = options.debounceMs ?? ANNOUNCE_DEBOUNCE_MS;
134
137
  const timeoutMs = options.timeoutMs ?? ANNOUNCE_ROUTER_TIMEOUT_MS;
138
+ // Resolved once, at construction: a node that cannot sign for its own peer id can only
139
+ // produce records the routers reject, so that is a config error to raise now, not an
140
+ // announce to make hourly and have refused (see MissingPrivateKeyError).
141
+ const signer = requireAnnounceSigner(options.libp2p);
135
142
  let started = false;
136
143
  let intervalTimer;
137
144
  let debounceTimer;
@@ -159,9 +166,19 @@ export function makeAnnouncer(options) {
159
166
  // announce nothing — the production router drops addr-less providers anyway.
160
167
  if (keys.length === 0 || addrs.length === 0)
161
168
  continue;
162
- const body = JSON.stringify({
163
- Providers: [{ Schema: "peer", Payload: { ID: options.libp2p.peerId.toString(), Addrs: addrs, Keys: keys } }]
164
- });
169
+ let body;
170
+ try {
171
+ // Fresh clock reading per tick: the router bounds a record's staleness and
172
+ // skew, so a cached timestamp ages into a rejection (see record.ts).
173
+ body = await signedProvidersBody({ peerId: options.libp2p.peerId.toString(), addrs, keys, timestamp: Date.now() }, signer);
174
+ }
175
+ catch (error) {
176
+ // A key that cannot sign fails every router identically; report it on each
177
+ // (the announcer never throws into the voter) and let the next tick retry.
178
+ for (const url of options.routerUrls)
179
+ options.onError?.(url, error);
180
+ continue;
181
+ }
165
182
  await Promise.all(options.routerUrls.map(async (url) => {
166
183
  try {
167
184
  await putProviders(url, body, timeoutMs);
@@ -0,0 +1,57 @@
1
+ import type { AnnounceSigner, AnnouncerLibp2p } from "./types.js";
2
+ /**
3
+ * The signed provider record the announcer PUTs (IPIP-0526, as implemented by the production
4
+ * router — pkc-http-router `lib/signature.ts`). Verification is ON by default there, so an
5
+ * unsigned record is a 403 for the WHOLE request: the announcing node is simply absent from
6
+ * every verifying router (issue #38 — four of six default routers rejected every announce).
7
+ *
8
+ * Two things make a record verifiable:
9
+ *
10
+ * - **`Signature`** — multibase (base64, the `m` prefix) over the sha256 digest of the raw
11
+ * `Payload` bytes, made by the private key the announcing peer id was derived from. libp2p's
12
+ * `PrivateKey.sign` is what the router's key types line up with: an ed25519 key signs those 32
13
+ * digest bytes directly (`crypto.verify(null, digest, key, sig)`), a secp256k1 key hashes them
14
+ * again through ECDSA (`crypto.verify("sha256", digest, key, sig)`) — both are exactly
15
+ * `sign(digest)` on this side. The public key is recovered from `Payload.ID` itself (an identity
16
+ * multihash), so nothing else has to travel.
17
+ * - **`Payload.Timestamp`** — epoch milliseconds, read fresh per announce: the router rejects a
18
+ * missing one, and bounds it against replay (24 h stale / 1 h future). Never cache it.
19
+ *
20
+ * **The bytes signed must be the bytes sent.** The router verifies against the `Payload` byte
21
+ * range it locates in the raw request body (`extractRawPayloads`), not against a re-serialized
22
+ * parse of it — so serializing the payload to sign it and then handing the enclosing object to
23
+ * `JSON.stringify` a second time is a real (and observed) failure mode: any difference in key
24
+ * order, spacing, or number formatting reads as `invalid_signature`. Hence this builder
25
+ * serializes the payload ONCE and splices that exact string into the body it returns; the body is
26
+ * a string all the way to `fetch`, and no object is ever re-serialized.
27
+ */
28
+ /** What the announcer signs with: libp2p's `PrivateKey` surface, narrowed to `sign`. */
29
+ export type { AnnounceSigner };
30
+ /**
31
+ * Find the injected node's signing key. libp2p keeps it on the `components` registry of the
32
+ * running node (it is deliberately absent from the public `Libp2p` interface, which exposes only
33
+ * the derived `peerId`); `privateKey` is checked first so a host that surfaces it directly — or a
34
+ * test double — is honoured without reaching into internals. Structural, like every other host
35
+ * probe in `transport/`: the injected node is `unknown` shaped, never trusted by type.
36
+ *
37
+ * Throws {@link MissingPrivateKeyError} when no key is reachable, at construction, rather than
38
+ * announcing records every verifying router will reject.
39
+ */
40
+ export declare function requireAnnounceSigner(libp2p: AnnouncerLibp2p): AnnounceSigner;
41
+ /** What one announce says: who, where, which CIDs, and when it was said. */
42
+ export interface AnnounceRecord {
43
+ /** The announcing node's peer id — also where the router recovers the verifying key from. */
44
+ peerId: string;
45
+ /** Already filtered/synthesized by the caller (see `announceableAddrs` / `sentinelAddrs`). */
46
+ addrs: readonly string[];
47
+ /** Every joined contest's criteria CID + checkpoint root + chunk CIDs, batched. */
48
+ keys: readonly string[];
49
+ /** Epoch ms, read fresh per announce (the router bounds staleness and skew). */
50
+ timestamp: number;
51
+ }
52
+ /**
53
+ * Build the signed `PUT /routing/v1/providers` body, as the exact string to send. Returns a
54
+ * string — not an object — on purpose: the signature covers the `Payload` substring inside it,
55
+ * and re-serializing anything here would break it (see the module comment).
56
+ */
57
+ export declare function signedProvidersBody(record: AnnounceRecord, signer: AnnounceSigner): Promise<string>;
@@ -0,0 +1,41 @@
1
+ import { base64 } from "multiformats/bases/base64";
2
+ import { sha256 } from "multiformats/hashes/sha2";
3
+ import { MissingPrivateKeyError } from "../../errors.js";
4
+ /**
5
+ * Find the injected node's signing key. libp2p keeps it on the `components` registry of the
6
+ * running node (it is deliberately absent from the public `Libp2p` interface, which exposes only
7
+ * the derived `peerId`); `privateKey` is checked first so a host that surfaces it directly — or a
8
+ * test double — is honoured without reaching into internals. Structural, like every other host
9
+ * probe in `transport/`: the injected node is `unknown` shaped, never trusted by type.
10
+ *
11
+ * Throws {@link MissingPrivateKeyError} when no key is reachable, at construction, rather than
12
+ * announcing records every verifying router will reject.
13
+ */
14
+ export function requireAnnounceSigner(libp2p) {
15
+ const direct = libp2p.privateKey;
16
+ if (isSigner(direct))
17
+ return direct;
18
+ const component = libp2p.components?.privateKey;
19
+ if (isSigner(component))
20
+ return component;
21
+ throw new MissingPrivateKeyError();
22
+ }
23
+ function isSigner(value) {
24
+ return value !== null && typeof value === "object" && typeof value.sign === "function";
25
+ }
26
+ /**
27
+ * Build the signed `PUT /routing/v1/providers` body, as the exact string to send. Returns a
28
+ * string — not an object — on purpose: the signature covers the `Payload` substring inside it,
29
+ * and re-serializing anything here would break it (see the module comment).
30
+ */
31
+ export async function signedProvidersBody(record, signer) {
32
+ const payloadJson = JSON.stringify({
33
+ ID: record.peerId,
34
+ Addrs: record.addrs,
35
+ Keys: record.keys,
36
+ Timestamp: record.timestamp
37
+ });
38
+ const digest = (await sha256.digest(new TextEncoder().encode(payloadJson))).digest;
39
+ const signature = base64.encode(await signer.sign(digest));
40
+ return `{"Providers":[{"Schema":"peer","Signature":${JSON.stringify(signature)},"Payload":${payloadJson}}]}`;
41
+ }
@@ -26,9 +26,28 @@ export interface AnnouncerLibp2p {
26
26
  getMultiaddrs(): Array<{
27
27
  toString(): string;
28
28
  }>;
29
+ /**
30
+ * The node's own signing key, if it is surfaced directly. libp2p's public `Libp2p` interface
31
+ * exposes only the derived `peerId`, so this is normally absent and the key is read from
32
+ * {@link AnnouncerLibp2p.components} instead — both are optional because the announcer probes
33
+ * the injected node structurally (see `requireAnnounceSigner`).
34
+ */
35
+ privateKey?: unknown;
36
+ /** libp2p's component registry on the running node — where `privateKey` actually lives. */
37
+ components?: {
38
+ privateKey?: unknown;
39
+ };
29
40
  addEventListener(type: "self:peer:update", listener: () => void): void;
30
41
  removeEventListener(type: "self:peer:update", listener: () => void): void;
31
42
  }
43
+ /**
44
+ * The signing surface a provider record needs: libp2p's `PrivateKey.sign`, narrowed to the one
45
+ * call the announcer makes. The announcer hands it the sha256 digest of the payload bytes and
46
+ * multibase-encodes what comes back — see `record.ts` for why that is what the router verifies.
47
+ */
48
+ export interface AnnounceSigner {
49
+ sign(data: Uint8Array): Uint8Array | Promise<Uint8Array>;
50
+ }
32
51
  export interface AnnouncerOptions {
33
52
  /** Delegated Routing V1 base URLs to PUT provider records to. Empty means never announce. */
34
53
  routerUrls: readonly string[];
@@ -95,6 +95,14 @@ export interface RootChaserDeps {
95
95
  deferVerify: (entries: PendingBundle[]) => void;
96
96
  /** Called once per chase that admitted at least one bundle (drives tally updates). */
97
97
  onMerged?: () => void;
98
+ /**
99
+ * Called once per successfully decoded checkpoint with EVERY bundle CID it referenced —
100
+ * including the ones this chase skipped because we already hold them. That inclusion is the
101
+ * point: a publisher's own bundle is by definition already held, so it never reaches
102
+ * {@link admit}, and "which peers are serving my vote back to me" would be unanswerable from
103
+ * the admit path alone. Reports what the checkpoint CONTAINED, not what was new.
104
+ */
105
+ onCheckpointContents?: (root: CID, cids: CID[]) => void;
98
106
  /** Concurrency limiter shared across chases (a root spray queues, never floods). */
99
107
  limit: <T>(fn: () => Promise<T>) => Promise<T>;
100
108
  /** Per-root deadline (ms); on expiry the abort signal fires and the chase yields nothing. */
@@ -41,7 +41,7 @@ export function toChaseSession(session) {
41
41
  };
42
42
  }
43
43
  export function makeRootChaser(deps) {
44
- const { getBlock, openSession, verifyOffline, cache, isEvaluableNow, hasBundle, admit, deferVerify, onMerged, limit, timeoutMs } = deps;
44
+ const { getBlock, openSession, verifyOffline, cache, isEvaluableNow, hasBundle, admit, deferVerify, onMerged, onCheckpointContents, limit, timeoutMs } = deps;
45
45
  const inFlight = new Map();
46
46
  function addProviders(flight, providers) {
47
47
  for (const peer of providers) {
@@ -112,6 +112,7 @@ export function makeRootChaser(deps) {
112
112
  return; // deadline hit — the hint contributed nothing
113
113
  let merged = false;
114
114
  const pending = [];
115
+ const contained = [];
115
116
  for (const bundle of winners) {
116
117
  if (controller.signal.aborted)
117
118
  break;
@@ -119,6 +120,7 @@ export function makeRootChaser(deps) {
119
120
  // canonical), so the CID matches the advertiser's block and dedups everywhere.
120
121
  const bytes = encodeBundle(bundle);
121
122
  const cid = await bundleCidForBytes(bytes);
123
+ contained.push(cid); // recorded BEFORE the skip below — see onCheckpointContents
122
124
  if (await hasBundle(cid))
123
125
  continue; // already held — nothing to verify
124
126
  const cached = cache.get(cid);
@@ -148,6 +150,8 @@ export function makeRootChaser(deps) {
148
150
  // and batches the gate reads (see verify/background.ts).
149
151
  if (pending.length > 0)
150
152
  deferVerify(pending);
153
+ if (contained.length > 0)
154
+ onCheckpointContents?.(root, contained);
151
155
  if (merged)
152
156
  onMerged?.();
153
157
  }
@@ -47,8 +47,13 @@ export interface GossipGateDeps {
47
47
  cid: CID;
48
48
  bundle: VotesBundle;
49
49
  }>;
50
- /** The full validity pipeline for one bundle (see verify/bundle.ts). */
51
- verifier: BundleVerifier;
50
+ /**
51
+ * The full validity pipeline for one bundle (see verify/bundle.ts). Declared as the `verify`
52
+ * half of {@link BundleVerifier}: the gate never runs a stage on its own (no `verifyOffline`,
53
+ * no `checkGates` — those belong to the cold-join chase and `Contest.checkEligibility`), and
54
+ * asking only for what it calls keeps that true.
55
+ */
56
+ verifier: Pick<BundleVerifier, "verify">;
52
57
  /**
53
58
  * Clock-aware freshness guard, kept OUT of the pure (cacheable) verifier: is this bundle's
54
59
  * bucket sample block already reachable from our chain head? A bundle dated to a future
@@ -44,5 +44,8 @@ export interface CachingBundleVerifier {
44
44
  * valid bundle or a provable `reject` is remembered (a known-bad bundle is not re-fetched or
45
45
  * re-checked), while a transient `ignore` is re-evaluated next time so a stale head/record
46
46
  * cannot pin it.
47
+ *
48
+ * Takes the `verify` half of a {@link BundleVerifier}: the cache memoizes whole-pipeline verdicts
49
+ * by CID and has nothing to say about the offline stage or a gate check, so it asks for neither.
47
50
  */
48
- export declare function makeCachingVerifier(verifier: BundleVerifier, cache: VerdictCache): CachingBundleVerifier;
51
+ export declare function makeCachingVerifier(verifier: Pick<BundleVerifier, "verify">, cache: VerdictCache): CachingBundleVerifier;
@@ -46,6 +46,9 @@ export function makeVerdictCache(maxEntries = 4096) {
46
46
  * valid bundle or a provable `reject` is remembered (a known-bad bundle is not re-fetched or
47
47
  * re-checked), while a transient `ignore` is re-evaluated next time so a stale head/record
48
48
  * cannot pin it.
49
+ *
50
+ * Takes the `verify` half of a {@link BundleVerifier}: the cache memoizes whole-pipeline verdicts
51
+ * by CID and has nothing to say about the offline stage or a gate check, so it asks for neither.
49
52
  */
50
53
  export function makeCachingVerifier(verifier, cache) {
51
54
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -25,6 +25,7 @@
25
25
  "build": "tsc -p tsconfig.json",
26
26
  "typecheck": "tsc -p tsconfig.json --noEmit",
27
27
  "typecheck:examples": "tsc -p tsconfig.examples.json --noEmit",
28
+ "typecheck:tests": "tsc -p tsconfig.test.json",
28
29
  "typecheck:bench": "npm run build && tsc -p benchmark/tsconfig.json --noEmit",
29
30
  "test": "vitest run",
30
31
  "test:coverage": "vitest run --coverage",