@bitsocial/pubsub-voting 0.0.10 → 0.1.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 +16 -4
- package/dist/chain/coalescer.js +4 -0
- package/dist/chain/types.d.ts +18 -6
- package/dist/client/voter.d.ts +7 -3
- package/dist/client/voter.js +9 -2
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +20 -0
- package/dist/schema/criteria.d.ts +19 -7
- package/dist/schema/criteria.js +18 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,22 +35,34 @@ The library never starts a node and never takes a host SDK (there is no `pkc` ar
|
|
|
35
35
|
| Seam | Type | Required | Purpose |
|
|
36
36
|
|---|---|---|---|
|
|
37
37
|
| `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`) |
|
|
38
|
-
| `chains` | `ChainClientFactory` | yes |
|
|
38
|
+
| `chains` | `ChainClientFactory` | yes | resolves each chain a contest's criteria requires (`{ chain, chainId }`) to a viem `PublicClient`; rules read through it for the gate and weight. **RPC endpoints are this client's own settings, never part of the criteria document** — return one shared (memoized) client per chain, pointed at a gateway that serves historical state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head and carries a multicall3 deployment in its viem `chain` config; return `undefined` for a chain with no RPC configured, and `createContest`/`createContestVote` throws `MissingChainClientError` (recuse, don't miscount) |
|
|
39
39
|
| `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
|
|
40
40
|
| `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 |
|
|
41
41
|
| `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 |
|
|
42
42
|
| `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 |
|
|
43
43
|
|
|
44
|
-
A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists.
|
|
44
|
+
A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry + the `chains` factory: an unimplemented rule throws `UnknownRuleError`, an unresolvable required chain throws `MissingChainClientError` — recuse, don't miscount), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists. The document names each required chain only by ticker + `chainId`; RPC endpoints stay out of it, so operators can swap gateways without forking the topic.
|
|
45
45
|
|
|
46
46
|
### Construct a voter
|
|
47
47
|
|
|
48
48
|
```ts
|
|
49
|
-
import { PubsubVoter } from "@bitsocial/pubsub-voting";
|
|
49
|
+
import { PubsubVoter, type ChainClientFactory } from "@bitsocial/pubsub-voting";
|
|
50
|
+
import { createPublicClient, http } from "viem";
|
|
51
|
+
import { base } from "viem/chains";
|
|
52
|
+
|
|
53
|
+
// The host's chain settings: which RPC gateway to trust per chain is THIS client's choice
|
|
54
|
+
// (never part of a criteria document). One shared client per chain, memoized — sharing is
|
|
55
|
+
// what lets parallel contests' pinned-block reads coalesce into shared multicalls.
|
|
56
|
+
const viemChainFactory = (): ChainClientFactory => {
|
|
57
|
+
const clients: Record<number, ReturnType<typeof createPublicClient>> = {
|
|
58
|
+
[base.id]: createPublicClient({ chain: base, transport: http("https://my-trusted-base-rpc.example") })
|
|
59
|
+
};
|
|
60
|
+
return ({ chainId }) => clients[chainId]; // undefined → recuse contests requiring that chain
|
|
61
|
+
};
|
|
50
62
|
|
|
51
63
|
const voter = new PubsubVoter({
|
|
52
64
|
helia, // the host's Helia node; needs a gossipsub service at libp2p.services.pubsub + a blockstore
|
|
53
|
-
chains: viemChainFactory(), // ({ chain,
|
|
65
|
+
chains: viemChainFactory(), // ({ chain, chainId }) => viem PublicClient | undefined
|
|
54
66
|
signer: mySigner, // optional; omit → read-only voter
|
|
55
67
|
nameResolvers: [bsoResolver], // optional; verifies community-name claims (e.g. @bitsocial/bso-resolver)
|
|
56
68
|
dataPath: "/path/to/data", // optional; persistent state: caches + checkpoint snapshots (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
|
package/dist/chain/coalescer.js
CHANGED
|
@@ -207,6 +207,10 @@ export function coalescingChainFactory(factory, options) {
|
|
|
207
207
|
const wrapped = new WeakMap();
|
|
208
208
|
return (args) => {
|
|
209
209
|
const client = factory(args);
|
|
210
|
+
// No client for this chain — the host has no RPC configured; the voter turns this
|
|
211
|
+
// into MissingChainClientError at the create seam.
|
|
212
|
+
if (client === undefined)
|
|
213
|
+
return undefined;
|
|
210
214
|
let coalesced = wrapped.get(client);
|
|
211
215
|
if (!coalesced) {
|
|
212
216
|
coalesced = coalescingChainClient(client, options);
|
package/dist/chain/types.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { PublicClient } from "viem";
|
|
2
|
-
import type { ChainConfig } from "../schema/criteria.js";
|
|
3
2
|
/**
|
|
4
3
|
* Chain access.
|
|
5
4
|
*
|
|
@@ -22,14 +21,27 @@ export type ChainClient = PublicClient;
|
|
|
22
21
|
/** chainTicker -> client, built from `criteria.requires.chains`. */
|
|
23
22
|
export type ChainClients = Record<string, ChainClient>;
|
|
24
23
|
/**
|
|
25
|
-
* Factory the host provides:
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* Factory the host provides: resolve a chain named by the criteria (`requires.chains`,
|
|
25
|
+
* ticker + chainId) to a viem `PublicClient`. The RPC endpoint is the HOST's setting —
|
|
26
|
+
* deliberately not part of the criteria document (see schema/criteria.ts,
|
|
27
|
+
* `ChainConfigSchema`) — so this factory is where ticker/chainId meets the gateways this
|
|
28
|
+
* client trusts (typically `createPublicClient({ chain, transport: http(myRpcUrl) })`).
|
|
29
|
+
*
|
|
30
|
+
* Return `undefined` (or throw) when no RPC is configured for the named chain: the voter
|
|
31
|
+
* then throws `MissingChainClientError` at the create seam (`createContest` /
|
|
32
|
+
* `createContestVote`) — this client must recuse the contest rather than miscount.
|
|
33
|
+
*
|
|
34
|
+
* Return ONE shared client per chain (memoize on `chainId`), not a fresh client per call:
|
|
35
|
+
* the voter wraps each distinct client with the cross-contest read coalescer
|
|
36
|
+
* (src/chain/coalescer.ts), so sharing is what merges parallel contests' pinned-block reads
|
|
37
|
+
* into shared multicalls under one in-flight budget. Pick a gateway that serves historical
|
|
38
|
+
* state at least `voteExpiryBuckets × blocksPerBucket` blocks behind head (gate reads pin
|
|
39
|
+
* to bucket sample blocks) and carries a multicall3 deployment in its viem `chain` config.
|
|
28
40
|
*/
|
|
29
41
|
export type ChainClientFactory = (args: {
|
|
30
42
|
chain: string;
|
|
31
|
-
|
|
32
|
-
}) => ChainClient;
|
|
43
|
+
chainId: number;
|
|
44
|
+
}) => ChainClient | undefined;
|
|
33
45
|
/**
|
|
34
46
|
* A community-name resolver the host injects (`PubsubVoterOptions.nameResolvers`). The
|
|
35
47
|
* shape is structurally identical to pkc-js's `NameResolverInterface`, so a host passes
|
package/dist/client/voter.d.ts
CHANGED
|
@@ -172,9 +172,13 @@ export interface PubsubVoterOptions {
|
|
|
172
172
|
*/
|
|
173
173
|
helia: HeliaInstance;
|
|
174
174
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
175
|
+
* Resolves a chain named by a contest's criteria (`requires.chains`, ticker + chainId)
|
|
176
|
+
* to a viem `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
|
|
177
|
+
* are deliberately not part of the criteria document, so this factory is where the host
|
|
178
|
+
* maps chains to the endpoints it trusts. Return one shared client per chain (memoized),
|
|
179
|
+
* and `undefined` for a chain with no RPC configured — `createContest` /
|
|
180
|
+
* `createContestVote` then throws `MissingChainClientError` (recuse, don't miscount).
|
|
181
|
+
* See `ChainClientFactory` (src/chain/types.ts) for the full contract.
|
|
178
182
|
*/
|
|
179
183
|
chains: ChainClientFactory;
|
|
180
184
|
/** Identity. Omit for a read-only voter (renders tallies, cannot publish). */
|
package/dist/client/voter.js
CHANGED
|
@@ -30,7 +30,7 @@ import { CID } from "multiformats/cid";
|
|
|
30
30
|
import { makeTally } from "../tally/tally.js";
|
|
31
31
|
import { ballotTypedData } from "../signer/eip712.js";
|
|
32
32
|
import { criteriaCid, TOPIC_PREFIX } from "../topic.js";
|
|
33
|
-
import { ReadOnlyError, UnknownRuleError, VoterDestroyedError } from "../errors.js";
|
|
33
|
+
import { MissingChainClientError, ReadOnlyError, UnknownRuleError, VoterDestroyedError } from "../errors.js";
|
|
34
34
|
/**
|
|
35
35
|
* The recommended cadence, in buckets, at which a client should re-publish a live vote to keep
|
|
36
36
|
* it alive: half its expiry window, rounded up. A bundle is valid for `voteExpiryBuckets` after
|
|
@@ -284,7 +284,14 @@ class ContestEngine {
|
|
|
284
284
|
this.readOnly = deps.signer === undefined;
|
|
285
285
|
this.#deps = deps;
|
|
286
286
|
this.#criteriaCid = criteriaCidBytes;
|
|
287
|
-
|
|
287
|
+
// Resolve every chain the manifest requires, eagerly: a client with no RPC configured
|
|
288
|
+
// for one of them must find out at the create seam (recuse), not on its first verify.
|
|
289
|
+
this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => {
|
|
290
|
+
const client = deps.chains({ chain, chainId: config.chainId });
|
|
291
|
+
if (client === undefined)
|
|
292
|
+
throw new MissingChainClientError(chain, config.chainId);
|
|
293
|
+
return [chain, client];
|
|
294
|
+
}));
|
|
288
295
|
// The gating (`rule`) chain fixes the ballot's chainId and the tie-break seed chain.
|
|
289
296
|
const rule = deps.registry[criteria.rule.type];
|
|
290
297
|
if (!rule)
|
package/dist/errors.d.ts
CHANGED
|
@@ -22,6 +22,18 @@ export declare class UnknownRuleError extends Error {
|
|
|
22
22
|
readonly type: string;
|
|
23
23
|
constructor(slot: "rule" | "weight" | "requires", type: string);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Thrown by `createContest` / `createContestVote` when the criteria's dependency manifest
|
|
27
|
+
* names a chain (`requires.chains`) the host's `ChainClientFactory` cannot resolve to a
|
|
28
|
+
* client (it returned `undefined`). RPC endpoints are client-local settings, not part of
|
|
29
|
+
* the criteria document, so a client with no gateway configured for a required chain must
|
|
30
|
+
* recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
|
|
31
|
+
*/
|
|
32
|
+
export declare class MissingChainClientError extends Error {
|
|
33
|
+
readonly chain: string;
|
|
34
|
+
readonly chainId: number;
|
|
35
|
+
constructor(chain: string, chainId: number);
|
|
36
|
+
}
|
|
25
37
|
/**
|
|
26
38
|
* Thrown at construction when the injected Helia node's libp2p has no usable pubsub
|
|
27
39
|
* (gossipsub) service at `libp2p.services.pubsub`. The library broadcasts and receives
|
package/dist/errors.js
CHANGED
|
@@ -32,6 +32,26 @@ export class UnknownRuleError extends Error {
|
|
|
32
32
|
this.name = "UnknownRuleError";
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by `createContest` / `createContestVote` when the criteria's dependency manifest
|
|
37
|
+
* names a chain (`requires.chains`) the host's `ChainClientFactory` cannot resolve to a
|
|
38
|
+
* client (it returned `undefined`). RPC endpoints are client-local settings, not part of
|
|
39
|
+
* the criteria document, so a client with no gateway configured for a required chain must
|
|
40
|
+
* recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
|
|
41
|
+
*/
|
|
42
|
+
export class MissingChainClientError extends Error {
|
|
43
|
+
chain;
|
|
44
|
+
chainId;
|
|
45
|
+
constructor(chain, chainId) {
|
|
46
|
+
super(`No chain client for "${chain}" (chainId ${chainId}), which this contest's criteria ` +
|
|
47
|
+
`requires. RPC endpoints are client settings, not part of the criteria document: ` +
|
|
48
|
+
`configure the \`chains\` factory (PubsubVoterOptions.chains) to return a viem ` +
|
|
49
|
+
`PublicClient for this chain, or recuse this contest.`);
|
|
50
|
+
this.chain = chain;
|
|
51
|
+
this.chainId = chainId;
|
|
52
|
+
this.name = "MissingChainClientError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
35
55
|
/**
|
|
36
56
|
* Thrown at construction when the injected Helia node's libp2p has no usable pubsub
|
|
37
57
|
* (gossipsub) service at `libp2p.services.pubsub`. The library broadcasts and receives
|
|
@@ -30,11 +30,25 @@ export declare const VoteRangeSchema: z.ZodObject<{
|
|
|
30
30
|
export declare const RuleRefSchema: z.ZodObject<{
|
|
31
31
|
type: z.ZodString;
|
|
32
32
|
}, z.core.$loose>;
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* One chain the contest reads, by ticker. Part of the dependency manifest.
|
|
35
|
+
*
|
|
36
|
+
* Only the `chainId` is here — it is consensus-critical (bound into every EIP-712 ballot
|
|
37
|
+
* domain and defining which chain the rules read). RPC endpoints are deliberately NOT part
|
|
38
|
+
* of the criteria: which gateway a client trusts is client-local transport configuration
|
|
39
|
+
* (`PubsubVoterOptions.chains` maps ticker/chainId to a client), and two honest verifiers
|
|
40
|
+
* reading the same pinned block through different gateways compute identical results. Keeping
|
|
41
|
+
* URLs out means an operator can swap a dead RPC provider without changing the document's
|
|
42
|
+
* bytes — i.e. without forking the topic and orphaning the contest's votes.
|
|
43
|
+
*
|
|
44
|
+
* Strict on purpose: the topic is derived from the PARSED document, so an unknown key must
|
|
45
|
+
* fail loudly here — a plain (stripping) object would silently drop it and derive a different
|
|
46
|
+
* topic than the author's raw document implies. This also makes pre-v1 documents that still
|
|
47
|
+
* carry `rpcUrls` a loud error instead of a silent re-topic.
|
|
48
|
+
*/
|
|
34
49
|
export declare const ChainConfigSchema: z.ZodObject<{
|
|
35
50
|
chainId: z.ZodNumber;
|
|
36
|
-
|
|
37
|
-
}, z.core.$strip>;
|
|
51
|
+
}, z.core.$strict>;
|
|
38
52
|
/**
|
|
39
53
|
* The dependency manifest. A client reads this on join and checks that it
|
|
40
54
|
* implements every named rule; if not, it is too old and must recuse
|
|
@@ -44,8 +58,7 @@ export declare const RequiresSchema: z.ZodObject<{
|
|
|
44
58
|
rules: z.ZodArray<z.ZodString>;
|
|
45
59
|
chains: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
46
60
|
chainId: z.ZodNumber;
|
|
47
|
-
|
|
48
|
-
}, z.core.$strip>>;
|
|
61
|
+
}, z.core.$strict>>;
|
|
49
62
|
}, z.core.$strip>;
|
|
50
63
|
export declare const CriteriaSchema: z.ZodObject<{
|
|
51
64
|
name: z.ZodString;
|
|
@@ -67,8 +80,7 @@ export declare const CriteriaSchema: z.ZodObject<{
|
|
|
67
80
|
rules: z.ZodArray<z.ZodString>;
|
|
68
81
|
chains: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
69
82
|
chainId: z.ZodNumber;
|
|
70
|
-
|
|
71
|
-
}, z.core.$strip>>;
|
|
83
|
+
}, z.core.$strict>>;
|
|
72
84
|
}, z.core.$strip>;
|
|
73
85
|
}, z.core.$strict>;
|
|
74
86
|
export type VoteRange = z.infer<typeof VoteRangeSchema>;
|
package/dist/schema/criteria.js
CHANGED
|
@@ -31,10 +31,24 @@ export const VoteRangeSchema = z.object({
|
|
|
31
31
|
export const RuleRefSchema = z.looseObject({
|
|
32
32
|
type: z.string().min(1)
|
|
33
33
|
});
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
/**
|
|
35
|
+
* One chain the contest reads, by ticker. Part of the dependency manifest.
|
|
36
|
+
*
|
|
37
|
+
* Only the `chainId` is here — it is consensus-critical (bound into every EIP-712 ballot
|
|
38
|
+
* domain and defining which chain the rules read). RPC endpoints are deliberately NOT part
|
|
39
|
+
* of the criteria: which gateway a client trusts is client-local transport configuration
|
|
40
|
+
* (`PubsubVoterOptions.chains` maps ticker/chainId to a client), and two honest verifiers
|
|
41
|
+
* reading the same pinned block through different gateways compute identical results. Keeping
|
|
42
|
+
* URLs out means an operator can swap a dead RPC provider without changing the document's
|
|
43
|
+
* bytes — i.e. without forking the topic and orphaning the contest's votes.
|
|
44
|
+
*
|
|
45
|
+
* Strict on purpose: the topic is derived from the PARSED document, so an unknown key must
|
|
46
|
+
* fail loudly here — a plain (stripping) object would silently drop it and derive a different
|
|
47
|
+
* topic than the author's raw document implies. This also makes pre-v1 documents that still
|
|
48
|
+
* carry `rpcUrls` a loud error instead of a silent re-topic.
|
|
49
|
+
*/
|
|
50
|
+
export const ChainConfigSchema = z.strictObject({
|
|
51
|
+
chainId: z.number().int().positive()
|
|
38
52
|
});
|
|
39
53
|
/**
|
|
40
54
|
* The dependency manifest. A client reads this on join and checks that it
|