@bitsocial/pubsub-voting 0.5.0 → 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 +38 -15
- package/dist/client/voter.d.ts +99 -18
- package/dist/client/voter.js +286 -40
- package/dist/errors.d.ts +0 -4
- package/dist/errors.js +0 -8
- package/dist/transport/chase.d.ts +8 -0
- package/dist/transport/chase.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,13 +34,12 @@ 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
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 |
|
|
@@ -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
|
|
@@ -89,7 +87,7 @@ const voter = new PubsubVoter({
|
|
|
89
87
|
|
|
90
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.)
|
|
91
89
|
|
|
92
|
-
### Read a tally reactively
|
|
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
|
|
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({
|
|
185
|
-
|
|
186
|
-
|
|
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**: `"
|
|
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
|
|
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
|
```
|
package/dist/client/voter.d.ts
CHANGED
|
@@ -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}
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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" | "
|
|
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: `
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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>;
|
package/dist/client/voter.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
760
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
1347
|
-
* {@link
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
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
|
-
|
|
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"`/`"
|
|
1974
|
+
* with `"publishing"`/`"published"`. Reset by the next `publish()` call.
|
|
1767
1975
|
*/
|
|
1768
1976
|
#evicted = false;
|
|
1769
|
-
|
|
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
|
-
//
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
-
|
|
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
|
@@ -87,10 +87,6 @@ export declare class DuplicateContestIdError extends Error {
|
|
|
87
87
|
readonly contestId: string;
|
|
88
88
|
constructor(contestId: string);
|
|
89
89
|
}
|
|
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
90
|
/**
|
|
95
91
|
* Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
|
|
96
92
|
* fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
|
package/dist/errors.js
CHANGED
|
@@ -133,14 +133,6 @@ export class DuplicateContestIdError extends Error {
|
|
|
133
133
|
this.name = "DuplicateContestIdError";
|
|
134
134
|
}
|
|
135
135
|
}
|
|
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
136
|
/**
|
|
145
137
|
* Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
|
|
146
138
|
* fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
|
|
@@ -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. */
|
package/dist/transport/chase.js
CHANGED
|
@@ -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
|
}
|