@bitsocial/pubsub-voting 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -26
- package/dist/chain/types.d.ts +10 -9
- package/dist/client/voter.d.ts +175 -34
- package/dist/client/voter.js +368 -106
- package/dist/errors.d.ts +6 -12
- package/dist/errors.js +8 -19
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/rules/erc20-balance.d.ts +0 -1
- package/dist/rules/erc20-balance.js +0 -2
- package/dist/rules/erc5192-min-balance.d.ts +0 -1
- package/dist/rules/erc5192-min-balance.js +0 -2
- package/dist/rules/erc721-min-balance.d.ts +0 -1
- package/dist/rules/erc721-min-balance.js +0 -2
- package/dist/rules/gate.d.ts +164 -0
- package/dist/rules/gate.js +209 -0
- package/dist/rules/registry.d.ts +11 -5
- package/dist/rules/registry.js +18 -9
- package/dist/schema/common.d.ts +5 -5
- package/dist/schema/common.js +5 -5
- package/dist/schema/criteria.d.ts +58 -29
- package/dist/schema/criteria.js +123 -23
- package/dist/signer/eip712.d.ts +2 -2
- package/dist/signer/eip712.js +1 -1
- package/dist/tally/tally.d.ts +2 -1
- package/dist/tally/tally.js +10 -14
- package/dist/tally/types.d.ts +1 -1
- package/dist/transport/chase.d.ts +8 -0
- package/dist/transport/chase.js +5 -1
- package/dist/transport/integration/harness.js +3 -3
- package/dist/verify/background.d.ts +8 -6
- package/dist/verify/background.js +57 -42
- package/dist/verify/bundle.d.ts +12 -10
- package/dist/verify/bundle.js +69 -38
- package/dist/verify/types.d.ts +37 -16
- package/package.json +1 -1
- package/dist/chain/ticker.d.ts +0 -15
- package/dist/chain/ticker.js +0 -25
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ This library does not start its own node. It consumes the host's running Helia n
|
|
|
24
24
|
|
|
25
25
|
- **Settings live in the topic.** `topic = "bitsocial-votes/" + CID(dag-cbor(criteria))`. Two peers on the same topic provably ran identical rules, so the network validates itself with no intermediary.
|
|
26
26
|
- **Votes are a state-based grow-only CRDT.** A signed `Votes` bundle is a standalone dag-cbor block (no parent links); each wallet gossips its own bundle **inline as a live delta**, validated straight from the message bytes — no fetch toward the publisher. State is a last-write-wins set keyed by wallet, so aggregation is a monotonic union: a peer can omit a vote but can never subtract one that an honest peer serves. Cold start and gap-fill exchange a tiny **root record** (libp2p-fetch pull + a slow topic heartbeat) and pull the checkpoint blocks behind it via directed bitswap from its advertisers.
|
|
27
|
-
- **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate
|
|
27
|
+
- **The gate and weight are data, not code.** A fixed rule registry (mirroring pkc-js's challenge registry) maps a `type` string to a verifier, and the criteria's `gate` composes those rules with `all` / `any` — so "the Pass, or a moderator, and not banned" is a document, not a custom rule. v1 ships exactly the soulbound-NFT path — an `erc5192-min-balance` gate (the 5chan Pass: `balanceOf` **plus** an on-chain assertion that the contract declares its tokens locked, read at the verifier's head so a freshly-acquired Pass votes immediately) and `constant` weight (1 pass = 1 vote). A gate on a *transferable* asset would let one Pass back several concurrent votes, so the plain `erc721-min-balance` rule ships unregistered; balance-derived (token-weighted) voting is deferred. See [DESIGN.md, Does one Pass mean one vote?](./DESIGN.md#does-one-pass-mean-one-vote) and [ROADMAP.md](./ROADMAP.md).
|
|
28
28
|
|
|
29
29
|
See [DESIGN.md](./DESIGN.md) for the full rationale, including how this resists vote-dropping and how criteria upgrades fork cleanly.
|
|
30
30
|
|
|
@@ -34,18 +34,28 @@ See [DESIGN.md](./DESIGN.md) for the full rationale, including how this resists
|
|
|
34
34
|
|
|
35
35
|
## Usage
|
|
36
36
|
|
|
37
|
-
The library never starts a node and never takes a host SDK (there is no `pkc` argument). A host passes its own running Helia node in directly and injects its seams into a single `PubsubVoter
|
|
37
|
+
The library never starts a node and never takes a host SDK (there is no `pkc` argument). A host passes its own running Helia node in directly and injects its seams into a single `PubsubVoter`. **Identity is not one of the seams**: the voting wallet (`VoteSigner`) belongs to each ballot, passed to `createContestVote`, so one voter on the host's shared node publishes for as many wallets as the host holds keys for — and a client that only renders tallies never touches key material.
|
|
38
38
|
|
|
39
39
|
| Seam | Type | Required | Purpose |
|
|
40
40
|
|---|---|---|---|
|
|
41
41
|
| `helia` | `HeliaInstance` | yes | the host's running Helia node; must carry a gossipsub service at `libp2p.services.pubsub` (else `MissingPubsubError`), a `blockstore` (else `MissingBlockstoreError`), and a libp2p fetch service at `libp2p.services.fetch` (else `MissingFetchError`) |
|
|
42
|
-
| `chains` | `ChainClientFactory` | yes | resolves
|
|
43
|
-
| `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
|
|
42
|
+
| `chains` | `ChainClientFactory` | yes | resolves the chain a contest counts in (`{ chainId }`, from `criteria.bucketChainId`) to a viem `PublicClient`; every gate rule and the weight rule read through it. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that carries a multicall3 deployment in its viem `chain` config and serves **historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head** (the v1 gate reads the head first, but falls back to the block a ballot names — see [Custom rules](#custom-rules)); return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
|
|
44
43
|
| `nameResolvers` | `NameResolver[]` | no | community-name resolvers (same interface and instances as pkc-js's `nameResolvers`, e.g. `@bitsocial/bso-resolver` for `name.bso`); each vote's `community.name` claim is verified through them — inline at the forward-gate for live votes, in the background verifier for cold-join admits — and a bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted |
|
|
45
44
|
| `dataPath` | `string \| false` | no | directory for the voter's persistent state (gate-result + name-resolution caches, and each joined contest's **checkpoint snapshot** — its last fully-verified winner-set, reloaded at join so a restart with no other peer online keeps the tally), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/` + `{dataPath}/checkpoints.db`); in the browser the path is ignored and everything lives in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC, and restores each contest's checkpoint before the cold-start pull. A seeder should always set a stable path |
|
|
46
45
|
| `httpRouterUrls` | `string[]` | no | Delegated Routing V1 router base URLs to **announce provider records to** (one unsigned `PUT /routing/v1/providers` per router; `Keys` batches every joined contest's criteria CID + current checkpoint root + chunk CIDs — hourly, debounced on root changes, and on address changes). **Seeders only**: absent/empty means never announce (the default — plain clients are not dialable), and the browser build never announces regardless. The node must be publicly **reachable** (its listening port open/forwarded/published), but it does not need to know its own public IP: private, loopback, and link-local addrs are filtered client-side, and when nothing survives — the normal zero-config case behind NAT or a Docker bridge, and even on public-IP hosts, since libp2p withholds unconfirmed public addrs pending AutoNAT — the announcer sends the wildcard sentinels (`/ip4/0.0.0.0/...`, `/ip6/::/...`) that the router rewrites to the PUT's observed source IP, exactly as kubo announces work. Configured `addresses.announce` values (concrete public addrs, DNS/AutoTLS, or a kubo-style wildcard) are used as-is. Only a loopback-only node announces nothing. *Querying* needs no URLs here — cold-join discovery uses the injected node's `libp2p.contentRouting`, which the host wires its routers into |
|
|
47
46
|
|
|
48
|
-
A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names
|
|
47
|
+
A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names its chain only by `bucketChainId`; RPC endpoints stay out of it, so operators can swap gateways without forking the topic.
|
|
48
|
+
|
|
49
|
+
Who may vote is the document's `gate` — one rule, or a boolean tree of them:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
gate: { rule: { type: "erc5192-min-balance", contract: "0x13d4…91b9", min: 1 } }
|
|
53
|
+
gate: { all: [{ rule: passRule }, { rule: notBannedRule }] } // every rule must admit
|
|
54
|
+
gate: { any: [{ rule: passRule }, { rule: moderatorRule }] } // any one of them admits
|
|
55
|
+
gate: { all: [{ any: [{ rule: passRule }, { rule: moderatorRule }] }, { rule: notBannedRule }] }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Each rule still answers one question about one wallet and knows nothing about the others; the document composes them. Because the topic is the CID of these bytes, the schema rejects the spellings that would mean the same as a shorter tree — a branch needs **at least two** children, may not repeat a child, and may not nest a branch of its own kind (`{ all: [{ all: [A, B] }, C] }` is just `{ all: [A, B, C] }`) — since each would put one contest on two topics. A rule may appear in two different branches, which is how a gate says "any two of these three": `{ any: [{ all: [A, B] }, { all: [A, C] }, { all: [B, C] }] }`. Trees nest at most 4 deep with at most 8 rules in total. No rule names a chain: every one of them reads the chain the contest counts in (`bucketChainId`), which is the chain the block each rule is handed comes from. Child order is significant and is the order the forward gate evaluates in, so put the cheapest or most discriminating rule first.
|
|
49
59
|
|
|
50
60
|
### Construct a voter
|
|
51
61
|
|
|
@@ -61,13 +71,12 @@ const viemChainFactory = (): ChainClientFactory => {
|
|
|
61
71
|
const clients: Record<number, ReturnType<typeof createPublicClient>> = {
|
|
62
72
|
[base.id]: createPublicClient({ chain: base, transport: http("https://my-trusted-base-rpc.example") })
|
|
63
73
|
};
|
|
64
|
-
return ({ chainId }) => clients[chainId]; // undefined → recuse contests
|
|
74
|
+
return ({ chainId }) => clients[chainId]; // undefined → recuse contests counting in that chain
|
|
65
75
|
};
|
|
66
76
|
|
|
67
77
|
const voter = new PubsubVoter({
|
|
68
78
|
helia, // the host's Helia node; needs a gossipsub service at libp2p.services.pubsub + a blockstore
|
|
69
|
-
chains: viemChainFactory(), // ({
|
|
70
|
-
signer: mySigner, // optional; omit → read-only voter
|
|
79
|
+
chains: viemChainFactory(), // ({ chainId }) => viem PublicClient | undefined
|
|
71
80
|
nameResolvers: [bsoResolver], // optional; verifies community-name claims (e.g. @bitsocial/bso-resolver)
|
|
72
81
|
dataPath: "/path/to/data", // optional; persistent state: caches + checkpoint snapshots (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
|
|
73
82
|
httpRouterUrls: [ // optional, SEEDERS ONLY (publicly reachable node): announce provider
|
|
@@ -78,7 +87,7 @@ const voter = new PubsubVoter({
|
|
|
78
87
|
|
|
79
88
|
Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingFetchError` if the node lacks a usable pubsub service, blockstore, or libp2p fetch service — the library fails fast rather than letting a later `publish`/`subscribe`/`fetch` fail obscurely. ("Bitswap" is not a separately checkable property — it is a block broker wired beneath `blockstore` — so the validated guarantee is a well-formed blockstore, the surface bitswap retrieves through. The fetch service carries the checkpoint root-record pull; the library registers its own responder on it.)
|
|
80
89
|
|
|
81
|
-
### Read a tally reactively
|
|
90
|
+
### Read a tally reactively
|
|
82
91
|
|
|
83
92
|
`createContest` mints a per-contest read object; `update()` starts syncing and it emits `update` (carrying a fresh `tally`) and `error`, just like a pkc-js `community`:
|
|
84
93
|
|
|
@@ -93,20 +102,47 @@ await contest.update(); // join the topic, col
|
|
|
93
102
|
|
|
94
103
|
### Will this wallet's vote count?
|
|
95
104
|
|
|
96
|
-
Ask the contest before signing. `checkEligibility` runs the contest's **real gate
|
|
97
|
-
the same chain
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
Ask the contest before signing. `checkEligibility` runs the contest's **real gate rules** through
|
|
106
|
+
the same chain clients, head reader and memos the forward gate uses, so each reads at whatever
|
|
107
|
+
block it reads at and applies whatever threshold it applies. When the gate refuses, the wording is
|
|
108
|
+
the rules' own — render it verbatim:
|
|
100
109
|
|
|
101
110
|
```ts
|
|
102
111
|
const check = await contest.checkEligibility({ address: wallet });
|
|
103
112
|
if (check.eligible) {
|
|
104
113
|
show(`eligible — holds ${check.score}`);
|
|
105
114
|
} else {
|
|
106
|
-
|
|
115
|
+
for (const failure of check.failures) show(failure.error); // e.g. "this wallet holds none of the gate token (0x13d4…91b9)"
|
|
107
116
|
}
|
|
108
117
|
```
|
|
109
118
|
|
|
119
|
+
With a composite gate you get one entry per rule, so a client can show a checklist rather than a
|
|
120
|
+
single verdict:
|
|
121
|
+
|
|
122
|
+
| field | what it is |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `checks` | every rule in the gate, in document order: `{ leaf, ruleId, type, satisfied, score, error? }` |
|
|
125
|
+
| `failures` | the rules whose failure **explains** the refusal — render these |
|
|
126
|
+
| `gate` | the same tree as `criteria.gate`, each node carrying its `satisfied`, for rendering the real requirement |
|
|
127
|
+
| `error` | `failures` joined into one sentence, for a caller that only wants a string |
|
|
128
|
+
|
|
129
|
+
`failures` is deliberately **not** `checks.filter(c => !c.satisfied)`. Under `any`, a wallet that
|
|
130
|
+
qualifies as a moderator also "fails" the Pass rule — telling it to go and buy a Pass would be
|
|
131
|
+
worse than saying nothing.
|
|
132
|
+
|
|
133
|
+
Key rows by **`leaf`** — the rule's position in the gate, and the only field guaranteed unique in
|
|
134
|
+
one result. `type` is not (a gate may name one rule twice on different options) and neither is
|
|
135
|
+
`ruleId` (a gate may name the same rule in two branches). Use `ruleId` to compare *across* results
|
|
136
|
+
instead: equal ids are one question, so a directory of 63 boards gated on one Pass shows the same
|
|
137
|
+
id everywhere, and shares one chain read behind it.
|
|
138
|
+
|
|
139
|
+
A check's `satisfied` is `true`, `false`, or **`undefined`** — the last meaning that rule's chain
|
|
140
|
+
read failed and the gate was decided without it. Render it as unknown, never as a requirement the
|
|
141
|
+
wallet is missing: nothing was learned about them. A wallet admitted by a branch that did answer
|
|
142
|
+
still gets `eligible: true` while an unrelated contract's RPC is down; the call throws only when
|
|
143
|
+
the gate cannot be decided without the rule that failed, because at that point there is no honest
|
|
144
|
+
answer to give.
|
|
145
|
+
|
|
110
146
|
Do **not** reimplement this by reading balances yourself: which block counts is the rule's
|
|
111
147
|
business and changes when the rule changes. A client that hard-codes "peers verify at the bucket
|
|
112
148
|
boundary" keeps telling voters to wait for a window that a head-reading gate no longer imposes.
|
|
@@ -138,31 +174,56 @@ A cold join **renders fast and refines**: checkpoint bundles are admitted after
|
|
|
138
174
|
|
|
139
175
|
Repeated `createContest` calls with byte-identical criteria return the same `Contest` (engines are keyed by topic, the criteria CID).
|
|
140
176
|
|
|
141
|
-
### Publish or withdraw a vote
|
|
177
|
+
### Publish or withdraw a vote
|
|
142
178
|
|
|
143
|
-
`createContestVote` mints a publishable ballot; `publish()` signs and broadcasts it once and emits `publishingstatechange`, like a pkc-js publication:
|
|
179
|
+
`createContestVote` mints a publishable ballot; `publish()` signs and broadcasts it once and emits `publishingstatechange`, like a pkc-js publication. The `signer` is the ballot's — the wallet that holds the Pass and whose recovered address *is* the voter:
|
|
144
180
|
|
|
145
181
|
```ts
|
|
146
|
-
const vote = await voter.createContestVote({
|
|
147
|
-
|
|
148
|
-
|
|
182
|
+
const vote = await voter.createContestVote({
|
|
183
|
+
criteria,
|
|
184
|
+
votes: [{ community: { publicKey: "12D3KooW..." }, vote: 1 }],
|
|
185
|
+
signer: mySigner // VoteSigner: address() + signBallot()
|
|
186
|
+
});
|
|
187
|
+
vote.on("publishingstatechange", (state) => console.log(state)); // stopped → signing → publishing → published, then verified-locally and/or verified-by-peer (or failed)
|
|
188
|
+
const { bundle, cid, recipientCount } = await vote.publish(); // the signed VotesBundle, its CID, and how many peers gossipsub sent it directly to
|
|
189
|
+
vote.signer === mySigner; // the ballot carries the wallet it was minted with
|
|
149
190
|
|
|
150
191
|
// Withdraw (active): publish an empty ballot; it supersedes the prior vote under LWW.
|
|
151
|
-
await (await voter.createContestVote({ criteria, votes: [] })).publish();
|
|
192
|
+
await (await voter.createContestVote({ criteria, votes: [], signer: mySigner })).publish();
|
|
152
193
|
```
|
|
153
194
|
|
|
195
|
+
A ballot is required to name its wallet, so a client that holds no key simply never mints one — there is no read-only mode to check, and nothing on an unpublished ballot to render. Two wallets on one voter are two `createContestVote` calls with two signers; the CRDT keys them apart by recovered address, so both land in the tally.
|
|
196
|
+
|
|
154
197
|
A community's identity is its `publicKey`. The optional `name` is the community's resolvable domain (e.g. `memes.bso`) — unique per community, never a free label: the schema requires a TLD, the name is resolved through the injected `nameResolvers` (inline at the forward-gate for live votes, in the background verifier for cold-join admits), and any bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted. Bundles must also name pairwise-distinct `community.publicKey`s. See [DESIGN.md, Votes wire](./DESIGN.md#votes-wire).
|
|
155
198
|
|
|
156
199
|
`recipientCount` is the peer-reach hint gossipsub reports: how many peers it sent the vote *directly* to at publish time (first-hop fan-out, filtered for send failures) — **not** total network reach, and **not** an acceptance confirmation, since each recipient still runs the forward-gate before re-forwarding. Treat it as a coarse "did this reach anyone?" signal. Note that gossipsub *rejects* the publish with `NoPeersSubscribedToTopic` when it would reach zero peers (common right after joining, before the mesh grafts), unless the host enables `allowPublishToZeroTopicPeers` — so a resolved `recipientCount === 0` only occurs under that host setting; otherwise a no-reach publish surfaces as a thrown error (and a `failed` state).
|
|
157
200
|
|
|
158
|
-
`publish()` on a voter built without a `signer` throws `ReadOnlyError` (and emits an `error`).
|
|
159
|
-
|
|
160
201
|
#### Rejection feedback
|
|
161
202
|
|
|
162
203
|
Gossipsub gives a publisher **no acceptance or rejection feedback** — a peer that drops a bundle does so silently. Since every honest peer runs the same checks this node runs, the library turns its own local verdict into the feedback the protocol can't provide, in two places:
|
|
163
204
|
|
|
164
205
|
- **At `publish()`**: each vote's `community.name` is preflighted through the shared resolution cache first — a name that definitively fails (no resolver for its TLD, no record, or it resolves to a **different** `publicKey` than the vote claims) throws `InvalidCommunityNameError` before signing or joining the topic, since every verifier would silently drop that bundle anyway. A resolver that merely *throws* (registry outage) never blocks the publish — the check stays deferred to the background verifier.
|
|
165
|
-
- **After `publish()` resolved**: `"
|
|
206
|
+
- **After `publish()` resolved**: `"published"` means signed and broadcast, **not** accepted by the network. The deferred checks (the on-chain gate read, and any name resolution a preflight outage skipped) run in the background; if one evicts the bundle, the vote emits a `VoteEvictedError` on its `error` event — carrying the evicted `bundle` and the exact `verdict` any verifier would produce — and its `publishingState` flips to `"failed"` post hoc. The same error fires on the contest's `error` event, for long-lived views.
|
|
207
|
+
|
|
208
|
+
The positive verdicts are states too, so a client never has to infer "it counted" from the absence of an error:
|
|
209
|
+
|
|
210
|
+
- `"verified-locally"` — our own deferred checks came back clean for this bundle. Still our verdict, but every honest peer runs byte-identical checks, so it is the strongest inference available without hearing from anyone.
|
|
211
|
+
- `"verified-by-peer"` — a peer advertised a checkpoint containing this bundle. A node serves only fully verified bundles in its own checkpoint, so an honest peer including it implies that peer verified it too; what is *observed* is that somebody other than us is keeping the vote.
|
|
212
|
+
|
|
213
|
+
The two are independent outcomes of two asynchronous races, not a sequence: a peer can serve our bundle back before our own gate read returns, in which case `"verified-by-peer"` is reached directly and `"verified-locally"` is never emitted. Peer evidence is the stronger of the two, so it is never walked back to the local verdict arriving late. Treat both as terminal-positive; do not wait for `"verified-locally"` before `"verified-by-peer"`.
|
|
214
|
+
|
|
215
|
+
Both are readable from the contest by bundle CID, which is how a restored vote asks after a reload — the publishing `ContestVote` is long gone by then, but the vote lives for `voteExpiryBuckets`. Persist `PublishOutcome.cid` and ask with it:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
contest.checksFor(cid); // { chainVerified, nameResolved? } — or undefined if not held (never admitted, evicted, expired)
|
|
219
|
+
contest.checkpointPeersFor(cid); // peer ids seen serving OUR bundle back in their checkpoint (own bundles only; a lower bound)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`checksFor` needs nothing extra: the checks are recorded on every admit path, including the snapshot restore. **`checkpointPeersFor` does**, because the engine cannot recognise a bundle it did not sign — the signer belongs to the publication, not the contest, so a restored bundle looks like any other wallet's. A client that persisted the CID re-arms attribution once, after `update()`:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
contest.trackOwnBundle(cid); // idempotent; attribution runs from here forward, not backwards
|
|
226
|
+
```
|
|
166
227
|
|
|
167
228
|
```ts
|
|
168
229
|
vote.on("error", (err) => {
|
|
@@ -173,7 +234,7 @@ await vote.publish(); // throws InvalidCommunityNameError if a carried name can'
|
|
|
173
234
|
|
|
174
235
|
### Republishing is the client's job
|
|
175
236
|
|
|
176
|
-
A vote is not permanent: a bundle is valid only for `voteExpiryBuckets` after its `blockNumber`, so a live vote must be re-published before it decays. **This library does not do that automatically** — it publishes each vote once and the consuming client decides when (or whether) to refresh. To refresh, just `createContestVote(...).publish()` again; a new bundle at the current bucket supersedes the old one. To stop, simply stop refreshing and let the vote lapse. The library gives you what you need to schedule it — all pure, no chain reads:
|
|
237
|
+
A vote is not permanent: a bundle is valid only for `voteExpiryBuckets` after its `blockNumber`, so a live vote must be re-published before it decays. **This library does not do that automatically** — it publishes each vote once and the consuming client decides when (or whether) to refresh. To refresh, just `createContestVote(...).publish()` again (with the same signer); a new bundle at the current bucket supersedes the old one. To stop, simply stop refreshing and let the vote lapse. The library gives you what you need to schedule it — all pure, no chain reads:
|
|
177
238
|
|
|
178
239
|
```ts
|
|
179
240
|
import { republishIntervalBuckets } from "@bitsocial/pubsub-voting";
|
|
@@ -207,7 +268,7 @@ There is no separate seeder API: a node that joins a topic (via `update()` or `p
|
|
|
207
268
|
`stop()` leaves every joined topic but keeps the voter **reusable** — each `Contest` can `update()` again and you can `createContest` afterward. `destroy()` is **terminal** (like pkc-js): it leaves every topic, unregisters the fetch responder, and marks the voter and its contests dead — any later `createContest`/`createContestVote`, or a pre-existing `Contest.update()`/`ContestVote.publish()`, throws `VoterDestroyedError`. Construct a new `PubsubVoter` to participate again. (There is no store to dispose — republishing is the client's concern.)
|
|
208
269
|
|
|
209
270
|
```ts
|
|
210
|
-
const voter = new PubsubVoter({ helia, chains
|
|
271
|
+
const voter = new PubsubVoter({ helia, chains });
|
|
211
272
|
// … create + update contests, app runs …
|
|
212
273
|
await voter.destroy(); // terminal: leave all topics, unregister the responder, forbid reuse
|
|
213
274
|
```
|
|
@@ -225,7 +286,7 @@ Full, type-checked call patterns for a pkc-js host, a plebbit/seedit host, and a
|
|
|
225
286
|
|
|
226
287
|
### Custom rules
|
|
227
288
|
|
|
228
|
-
The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` — where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **
|
|
289
|
+
The gate and weight are a single flat registry of rules, one `type` per file, mirroring the pkc-js challenge registry. Each rule owns its option schema, its own reads (`readContract`, `getBalance`, ... through `ctx.chain`, the viem `PublicClient` for its `options.chain`), which block it reads at, and what it memoizes — see [What a rule owns](#what-a-rule-owns-its-block-and-its-cache) below. There is **one kind**: `evaluate → RuleResult`, either `{ success: true, score }` with a positive score or `{ success: false, error }` — where `error` is the voter-facing reason the rule refused. The criteria has two *slots* drawing from the one registry — the **gate** slot treats the score as admission (`> 0n` admits), the **weight** slot as the vote's magnitude. A wallet's vote counts as `gate admits ? weight.score : 0n`. A rule never sees the gate it sits in: composition (`all` / `any`), which failures a voter is shown, and whether a refusal may be blamed on the sender are all folded by the library from what each rule returned. A rule that needs a threshold fails below it (so `erc5192-min-balance`'s optional `min` gates), which lets the same rule serve either slot. A chain-reading rule may also implement the optional `evaluateMany({ options, wallets, ctx })` batch hook (`wallets` in place of `wallet`, returning `{ results }` — one per input wallet, in order) — its semantics MUST equal mapping `evaluate` — which the background verifier uses to batch a cold join's gate reads. The batch is simply everything pending, so its wallets need not share a `sampleBlock`: a rule reading the head scores them all at once, a rule reading pinned blocks groups them itself. (`erc5192-min-balance` implements it over multicall3, hoisting its one lock assertion out of the per-wallet reads; see [DESIGN.md, Background chain verification](./DESIGN.md#background-chain-verification).)
|
|
229
290
|
|
|
230
291
|
Built-ins: `erc5192-min-balance` (v1) and `constant` (v1).
|
|
231
292
|
|
|
@@ -298,6 +359,8 @@ type RuleResult =
|
|
|
298
359
|
|
|
299
360
|
**`penalize`** answers the one thing the library cannot: may the failure be blamed on the sender? `true` (the default) says every honest verifier computes this same failure — true of a read pinned to the block the bundle names — so the forward gate `reject`s the message (penalizing the delivering peer in gossipsub's scoring) and the verdict is cached as terminal. `false` says an honest peer could legitimately disagree, so the bundle is dropped `ignore`-class instead: no penalty, verdict uncached, and the background verifier re-examines it for a grace window before giving up.
|
|
300
361
|
|
|
362
|
+
Across a composite gate the library folds those answers, and the fold is not a simple OR. An `all` fails as soon as one child does, so **one** attributable failure makes the whole refusal attributable — that rule alone closes the gate identically everywhere. An `any` fails only when every alternative does, so it is attributable only if **every** one of them is: a single unprovable failure means a peer on a fresher chain may be looking at a wallet this gate would admit, and penalizing it would punish honest relaying.
|
|
363
|
+
|
|
301
364
|
`erc5192-min-balance` reads the head first — so a freshly-acquired Pass counts immediately — falls back to `wallet.sampleBlock` when the head refuses (ERC-5192 does not forbid burning, and without the fallback a burn would erase votes retroactively for peers that had not verified them yet), memoizes each leg under its own epoch, and returns `penalize: false`, because at validation time it cannot attribute a failure to anyone: the peer that forwarded the vote verified it against *its* head. Every other rule in the tree reads `wallet.sampleBlock` and leaves `penalize` at its default — a transferable or fungible balance can decrease, so reading it at the head would silently invalidate votes already counted. See [DESIGN.md, What a rule owns](./DESIGN.md#what-a-rule-owns-and-what-the-pipeline-owns).
|
|
302
365
|
|
|
303
366
|
### Weighted voting (deferred)
|
package/dist/chain/types.d.ts
CHANGED
|
@@ -18,16 +18,18 @@ import type { PublicClient } from "viem";
|
|
|
18
18
|
* balance reads or chainTicker-to-RPC mapping. All of this is net-new here.
|
|
19
19
|
*/
|
|
20
20
|
export type ChainClient = PublicClient;
|
|
21
|
-
/** chainTicker -> client, built from `criteria.requires.chains`. */
|
|
22
|
-
export type ChainClients = Record<string, ChainClient>;
|
|
23
21
|
/**
|
|
24
|
-
* Factory the host provides: resolve
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
22
|
+
* Factory the host provides: resolve the chain a contest counts in (`criteria.bucketChainId`)
|
|
23
|
+
* to a viem `PublicClient`. The RPC endpoint is the HOST's setting — deliberately not part of
|
|
24
|
+
* the criteria document (see schema/criteria.ts, `bucketChainId`) — so this factory is where a
|
|
25
|
+
* chain id meets the gateways this client trusts (typically
|
|
26
|
+
* `createPublicClient({ chain, transport: http(myRpcUrl) })`).
|
|
29
27
|
*
|
|
30
|
-
*
|
|
28
|
+
* Keyed by chain ID alone, because that is the identity the criteria carries and the EIP-712
|
|
29
|
+
* ballot domain is signed over. A contest reads exactly one chain: every gate rule and the
|
|
30
|
+
* weight rule are handed this client (see DESIGN.md "One clock").
|
|
31
|
+
*
|
|
32
|
+
* Return `undefined` (or throw) when no RPC is configured for that chain: the voter
|
|
31
33
|
* then throws `MissingChainClientError` at the create seam (`createContest` /
|
|
32
34
|
* `createContestVote`) — this client must recuse the contest rather than miscount.
|
|
33
35
|
*
|
|
@@ -39,7 +41,6 @@ export type ChainClients = Record<string, ChainClient>;
|
|
|
39
41
|
* to bucket sample blocks) and carries a multicall3 deployment in its viem `chain` config.
|
|
40
42
|
*/
|
|
41
43
|
export type ChainClientFactory = (args: {
|
|
42
|
-
chain: string;
|
|
43
44
|
chainId: number;
|
|
44
45
|
}) => ChainClient | undefined;
|
|
45
46
|
/**
|
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
|
|
@@ -64,19 +89,73 @@ export interface PublishOutcome {
|
|
|
64
89
|
*/
|
|
65
90
|
readonly recipientCount: number;
|
|
66
91
|
}
|
|
67
|
-
/** One contest's reactive read view: subscribe, keep the tally in sync, read it. */
|
|
68
92
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
93
|
+
* One rule's answer about one wallet, as {@link Contest.checkEligibility} reports it.
|
|
94
|
+
* Shaped like {@link RuleResult} on purpose — it is that result, surfaced — so a failure always
|
|
95
|
+
* carries a sentence a client can display.
|
|
96
|
+
*/
|
|
97
|
+
export interface EligibilityCheck {
|
|
98
|
+
/**
|
|
99
|
+
* This leaf's position in the gate, depth-first in document order — the render key, and the
|
|
100
|
+
* only field guaranteed unique within one result. `type` is not (a gate may name one rule
|
|
101
|
+
* twice on different options) and neither is `ruleId` (a gate may name the SAME rule in two
|
|
102
|
+
* branches, which is how "any two of these three" is written).
|
|
103
|
+
*/
|
|
104
|
+
readonly leaf: number;
|
|
105
|
+
/**
|
|
106
|
+
* Stable identity of this rule INSTANCE — the hash of its canonical criteria reference plus
|
|
107
|
+
* the id of the chain it reads, which is also the namespace of its memo. Equal ids mean one
|
|
108
|
+
* question, so they are what to compare ACROSS results: two contests of a 63-board directory
|
|
109
|
+
* gated on one Pass share this id, and share every chain read behind it. Within one gate two
|
|
110
|
+
* leaves may share it; use {@link leaf} to tell them apart.
|
|
111
|
+
*/
|
|
112
|
+
readonly ruleId: string;
|
|
113
|
+
/** The rule `type` this leaf names, for grouping and labelling. */
|
|
114
|
+
readonly type: string;
|
|
115
|
+
/**
|
|
116
|
+
* `undefined` when this rule has no answer: its chain read failed and the gate was decided
|
|
117
|
+
* without it. Render it as unknown, never as a failure — nothing was learned about the wallet.
|
|
118
|
+
*/
|
|
119
|
+
readonly satisfied: boolean | undefined;
|
|
120
|
+
/** This rule's score for the wallet; `0n` unless it passed. */
|
|
121
|
+
readonly score: bigint;
|
|
122
|
+
/** The rule's own explanation, written for the voter. Render it verbatim. */
|
|
123
|
+
readonly error?: string;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The gate tree with each node's verdict — the same shape as `criteria.gate`, so a client can
|
|
127
|
+
* render the real requirement ("the Pass AND not banned", "the Pass OR moderator") instead of a
|
|
128
|
+
* flat list that cannot express it.
|
|
129
|
+
*/
|
|
130
|
+
export type EligibilityNode = ({
|
|
131
|
+
kind: "leaf";
|
|
132
|
+
} & EligibilityCheck) | {
|
|
133
|
+
kind: "all" | "any";
|
|
134
|
+
satisfied: boolean | undefined;
|
|
135
|
+
children: EligibilityNode[];
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* What {@link Contest.checkEligibility} found.
|
|
139
|
+
*
|
|
140
|
+
* `checks` is every rule in the gate, in document order. `failures` is the subset that EXPLAINS a
|
|
141
|
+
* refusal, which is not the same thing: a rule that failed inside a satisfied `any` branch cost
|
|
142
|
+
* the wallet nothing, and telling someone to go acquire an asset they do not need is worse than
|
|
143
|
+
* saying nothing. Render `failures`; use `gate` when the shape of the requirement matters.
|
|
71
144
|
*/
|
|
72
145
|
export type EligibilityResult = {
|
|
73
146
|
eligible: true;
|
|
74
|
-
/** The
|
|
147
|
+
/** The gate's folded score (min across an `all`, max across an `any`), always `> 0n`. */
|
|
75
148
|
score: bigint;
|
|
149
|
+
readonly checks: readonly EligibilityCheck[];
|
|
150
|
+
readonly gate: EligibilityNode;
|
|
76
151
|
} | {
|
|
77
152
|
eligible: false;
|
|
78
|
-
/** The
|
|
153
|
+
/** The blame set's reasons joined — the one-string form, for a caller that wants one. */
|
|
79
154
|
error: string;
|
|
155
|
+
/** The rules whose failure explains the refusal. Never empty on this branch. */
|
|
156
|
+
readonly failures: readonly EligibilityCheck[];
|
|
157
|
+
readonly checks: readonly EligibilityCheck[];
|
|
158
|
+
readonly gate: EligibilityNode;
|
|
80
159
|
};
|
|
81
160
|
export interface Contest {
|
|
82
161
|
/** The criteria document this contest runs (already validated). */
|
|
@@ -100,28 +179,75 @@ export interface Contest {
|
|
|
100
179
|
getTally(): Promise<ContestTally>;
|
|
101
180
|
/**
|
|
102
181
|
* Would this contest's gate admit `address` right now? Ask before signing, to tell a voter
|
|
103
|
-
* whether their ballot will count — and, when it will not, exactly
|
|
182
|
+
* whether their ballot will count — and, when it will not, exactly which rules refused.
|
|
104
183
|
*
|
|
105
|
-
* This runs the contest's REAL gate
|
|
106
|
-
* background verifier use: the same chain
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
184
|
+
* This runs the contest's REAL gate rules through the same context the forward-gate and the
|
|
185
|
+
* background verifier use: the same chain clients, the same coalesced head reader, the same
|
|
186
|
+
* memos. So it reads at whatever block each rule reads at, applies whatever thresholds they
|
|
187
|
+
* apply, and returns their own {@link RuleResult.error} wording verbatim. A client renders
|
|
188
|
+
* those sentences and needs to know nothing about blocks, buckets or thresholds — which is
|
|
110
189
|
* the point: re-deriving any of that outside the rule is how a UI ends up confidently
|
|
111
190
|
* telling voters to wait for a window that no longer gates anything.
|
|
112
191
|
*
|
|
192
|
+
* Every leaf is evaluated, even once the outcome is settled, because naming each failure is
|
|
193
|
+
* what this call is for (the forward gate, which only needs the verdict, stops early instead).
|
|
194
|
+
* A rule whose chain read fails comes back `satisfied: undefined` rather than sinking the
|
|
195
|
+
* whole answer — a wallet admitted by a branch that did answer is still told so. It throws
|
|
196
|
+
* only when the gate cannot be decided without the rule that failed.
|
|
197
|
+
*
|
|
113
198
|
* It is a courtesy check, not a promise. Eligibility is a fact about the chain and can change
|
|
114
199
|
* between this call and the publish, and each peer verifies against its own view — so a
|
|
115
200
|
* `true` here can still be followed by a `VoteEvictedError` (which carries the same kind of
|
|
116
201
|
* reason). `publish()` deliberately does NOT call this: the gate is the network's decision,
|
|
117
202
|
* and refusing locally would only hide a vote the rest of the topic would have accepted.
|
|
118
203
|
*
|
|
119
|
-
* Costs one gate
|
|
120
|
-
* would do anyway, not
|
|
204
|
+
* Costs one evaluation per gate rule, usually served from the shared memos — the same reads
|
|
205
|
+
* the verifier would do anyway, not extra ones.
|
|
121
206
|
*/
|
|
122
207
|
checkEligibility(args: {
|
|
123
208
|
address: string;
|
|
124
209
|
}): Promise<EligibilityResult>;
|
|
210
|
+
/**
|
|
211
|
+
* The deferred-check state of one bundle by CID, or `undefined` if this contest is not
|
|
212
|
+
* holding that bundle (never admitted, evicted, or expired — the three are not
|
|
213
|
+
* distinguishable here, and none of them means "counted").
|
|
214
|
+
*
|
|
215
|
+
* The publisher's question after a reload. A `ContestVote` reports its own bundle's progress
|
|
216
|
+
* through `publishingstatechange`, but that object dies with the page, while a vote lives for
|
|
217
|
+
* `voteExpiryBuckets` — so a tab that restored a vote from its own storage asks here instead,
|
|
218
|
+
* with the CID from {@link PublishOutcome.cid}.
|
|
219
|
+
*
|
|
220
|
+
* `{ chainVerified: true, nameResolved: true | undefined }` is the fully settled state (the
|
|
221
|
+
* same condition that drives `"verified-locally"`). `chainVerified: false` means NOT YET
|
|
222
|
+
* READ, never "failed" — a failed gate evicts the bundle, and this returns `undefined`.
|
|
223
|
+
*/
|
|
224
|
+
checksFor(cid: CID): BundleChecks | undefined;
|
|
225
|
+
/**
|
|
226
|
+
* The peers seen advertising a checkpoint that contained `cid` — for THIS wallet's own
|
|
227
|
+
* published bundles only (see DESIGN.md "Checkpoints"). Empty for anything else, including
|
|
228
|
+
* other wallets' bundles, which this node deliberately does not index.
|
|
229
|
+
*
|
|
230
|
+
* What it proves, exactly: a node serves only fully verified bundles in its own checkpoint,
|
|
231
|
+
* so an honest peer including ours implies that peer verified it too — but honesty is not
|
|
232
|
+
* provable, and a peer may serve whatever it likes. So read this as evidence of RETENTION
|
|
233
|
+
* and propagation ("somebody other than us is keeping this vote"), and only inferentially of
|
|
234
|
+
* verification. It is also a lower bound in a second way: it counts peers whose checkpoints
|
|
235
|
+
* we happened to chase, not every peer holding the vote.
|
|
236
|
+
*/
|
|
237
|
+
checkpointPeersFor(cid: CID): string[];
|
|
238
|
+
/**
|
|
239
|
+
* Declare that `cid` is one of THIS client's own published bundles, so peer-checkpoint
|
|
240
|
+
* attribution starts tracking it. Only needed after a restart: within one session
|
|
241
|
+
* `publish()` registers its own bundle, but the engine cannot recognise a bundle it did not
|
|
242
|
+
* sign — the signer belongs to the publication, not to the contest, and a restored bundle
|
|
243
|
+
* arrives through the snapshot like any other wallet's.
|
|
244
|
+
*
|
|
245
|
+
* So a client that persisted {@link PublishOutcome.cid} calls this after `update()` to
|
|
246
|
+
* re-arm {@link checkpointPeersFor}. Attribution runs from the call forward, not backwards:
|
|
247
|
+
* the first peer credited is the next one seen advertising a checkpoint that holds the
|
|
248
|
+
* bundle — at most one heartbeat interval away on a live topic. Idempotent.
|
|
249
|
+
*/
|
|
250
|
+
trackOwnBundle(cid: CID): void;
|
|
125
251
|
/**
|
|
126
252
|
* Fired when incoming votes change the state; `tally` carries the freshly recomputed
|
|
127
253
|
* ranking. Background check settlements fire it too: a cold join emits a first tally with
|
|
@@ -146,21 +272,34 @@ export interface ContestVote {
|
|
|
146
272
|
readonly topic: string;
|
|
147
273
|
/** The votes this ballot will sign and broadcast (empty array = a withdrawal). */
|
|
148
274
|
readonly votes: readonly Vote[];
|
|
275
|
+
/**
|
|
276
|
+
* The wallet that signs this ballot. Identity is per-ballot, not per-voter: one
|
|
277
|
+
* `PubsubVoter` on the host's shared node publishes for as many wallets as the host holds
|
|
278
|
+
* keys for, and the address recovered from the signature is the only voter identity there
|
|
279
|
+
* is (see DESIGN.md "Identity: the voting wallet, nothing else").
|
|
280
|
+
*/
|
|
281
|
+
readonly signer: VoteSigner;
|
|
149
282
|
/** Where in the publish lifecycle this ballot is. */
|
|
150
283
|
readonly publishingState: PublishingState;
|
|
151
284
|
/** The signed bundle, once `publish()` has produced it (`undefined` before then). */
|
|
152
285
|
readonly bundle: VotesBundle | undefined;
|
|
286
|
+
/**
|
|
287
|
+
* This ballot's deferred-check state — which of the two network checks have settled — or
|
|
288
|
+
* `undefined` before it is signed, and once the bundle has left the working set (evicted or
|
|
289
|
+
* expired). The detail behind `"verified-locally"`, for a UI that wants to say WHICH check is
|
|
290
|
+
* still outstanding rather than just "pending".
|
|
291
|
+
*/
|
|
292
|
+
readonly checks: BundleChecks | undefined;
|
|
153
293
|
/**
|
|
154
294
|
* Sign the votes into a bundle for the current bucket, add it to the local CRDT, and broadcast
|
|
155
295
|
* it once as a live delta. Joins the topic first if needed. Resolves a {@link PublishOutcome}:
|
|
156
296
|
* the `VotesBundle` (whose `blockNumber` the client uses to schedule its own refresh — see
|
|
157
297
|
* {@link republishIntervalBuckets}) plus `recipientCount`, the number of peers gossipsub sent
|
|
158
298
|
* the vote directly to. Emits `publishingstatechange` as it goes; throws (and emits `error`) on
|
|
159
|
-
* failure: `
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* the publish). This library does not re-publish: to keep the vote alive, call `publish()`
|
|
299
|
+
* failure: `InvalidCommunityNameError` when a vote's carried `community.name` definitively does
|
|
300
|
+
* not resolve to its claimed `publicKey` (checked BEFORE signing or joining — every verifier
|
|
301
|
+
* drops such a bundle silently, so it is refused here instead of published into a network-wide
|
|
302
|
+
* silent drop; a resolver outage does not block the publish). This library does not re-publish: to keep the vote alive, call `publish()`
|
|
164
303
|
* again before it expires.
|
|
165
304
|
*/
|
|
166
305
|
publish(): Promise<PublishOutcome>;
|
|
@@ -177,8 +316,6 @@ export interface ContestVote {
|
|
|
177
316
|
}
|
|
178
317
|
/** The factory: one set of injected dependencies, many contests. */
|
|
179
318
|
export interface VoteClient {
|
|
180
|
-
/** True when constructed without a signer: every contest is read-only. */
|
|
181
|
-
readonly readOnly: boolean;
|
|
182
319
|
/**
|
|
183
320
|
* Create the reactive read view for one contest from its full criteria document. The document
|
|
184
321
|
* is strictly validated (`CriteriaSchema` + the rule registry — an unimplemented rule throws
|
|
@@ -193,10 +330,16 @@ export interface VoteClient {
|
|
|
193
330
|
* Create a publishable ballot for one contest, validated and addressed exactly like
|
|
194
331
|
* `createContest`. Each call returns a fresh `ContestVote` over the shared per-topic engine;
|
|
195
332
|
* pass `votes: []` to build a withdrawal.
|
|
333
|
+
*
|
|
334
|
+
* The `signer` is per-ballot, and required. Identity belongs to the vote rather than to the
|
|
335
|
+
* client, so one voter on the host's shared node can publish for several wallets; and a
|
|
336
|
+
* ballot that cannot be signed has nothing to show (every other field on it is
|
|
337
|
+
* publish-derived), so reading a contest is `createContest`'s job, not this one's.
|
|
196
338
|
*/
|
|
197
339
|
createContestVote(args: {
|
|
198
340
|
criteria: Criteria;
|
|
199
341
|
votes: Vote[];
|
|
342
|
+
signer: VoteSigner;
|
|
200
343
|
}): Promise<ContestVote>;
|
|
201
344
|
/**
|
|
202
345
|
* Leave every topic this client joined, resetting each read view so it can `update()` again.
|
|
@@ -228,17 +371,15 @@ export interface PubsubVoterOptions {
|
|
|
228
371
|
*/
|
|
229
372
|
helia: HeliaInstance;
|
|
230
373
|
/**
|
|
231
|
-
* Resolves
|
|
232
|
-
*
|
|
374
|
+
* Resolves the chain a contest counts in (`criteria.bucketChainId`) to a viem
|
|
375
|
+
* `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
|
|
233
376
|
* are deliberately not part of the criteria document, so this factory is where the host
|
|
234
|
-
* maps
|
|
377
|
+
* maps chain ids to the endpoints it trusts. Return one shared client per chain (memoized),
|
|
235
378
|
* and `undefined` for a chain with no RPC configured — `createContest` /
|
|
236
379
|
* `createContestVote` then throws `MissingChainClientError` (recuse, don't miscount).
|
|
237
380
|
* See `ChainClientFactory` (src/chain/types.ts) for the full contract.
|
|
238
381
|
*/
|
|
239
382
|
chains: ChainClientFactory;
|
|
240
|
-
/** Identity. Omit for a read-only voter (renders tallies, cannot publish). */
|
|
241
|
-
signer?: VoteSigner;
|
|
242
383
|
/** Rule overrides that shadow built-ins by `type` (a flat `type -> rule` map). */
|
|
243
384
|
rules?: RuleRegistry;
|
|
244
385
|
/**
|
|
@@ -319,13 +460,13 @@ export declare function makeHeadReader(): (chain: ChainClient) => Promise<bigint
|
|
|
319
460
|
export declare class PubsubVoter implements VoteClient {
|
|
320
461
|
#private;
|
|
321
462
|
constructor(options: PubsubVoterOptions);
|
|
322
|
-
get readOnly(): boolean;
|
|
323
463
|
createContest(args: {
|
|
324
464
|
criteria: Criteria;
|
|
325
465
|
}): Promise<Contest>;
|
|
326
466
|
createContestVote(args: {
|
|
327
467
|
criteria: Criteria;
|
|
328
468
|
votes: Vote[];
|
|
469
|
+
signer: VoteSigner;
|
|
329
470
|
}): Promise<ContestVote>;
|
|
330
471
|
stop(): Promise<void>;
|
|
331
472
|
destroy(): Promise<void>;
|