@bitsocial/pubsub-voting 0.0.9 → 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 +64 -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/dist/transport/types.d.ts +19 -0
- 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
|
|
@@ -121,6 +121,14 @@ const COLD_START_FETCH_BACKOFF_CAP_MS = 4_000;
|
|
|
121
121
|
* others idle.
|
|
122
122
|
*/
|
|
123
123
|
const COLD_START_PEER_FETCH_LIMIT = 24;
|
|
124
|
+
/**
|
|
125
|
+
* How long after `join()` the cold-start pull stays armed on gossipsub's `subscription-change`
|
|
126
|
+
* (see `#armSubscriptionRepull`). One heartbeat interval: the window exists to close the
|
|
127
|
+
* join-races-subscription-gossip gap (issue #15), and past the first heartbeat the passive
|
|
128
|
+
* root-record heartbeat covers divergence detection anyway — an unbounded listener would
|
|
129
|
+
* instead pay one root fetch per churning subscriber for the node's whole lifetime.
|
|
130
|
+
*/
|
|
131
|
+
const COLD_START_REPULL_WINDOW_MS = HEARTBEAT_INTERVAL_MS;
|
|
124
132
|
/**
|
|
125
133
|
* Debounce (ms) for persisting the checkpoint snapshot after a winner-set change, mirroring the
|
|
126
134
|
* announcer's cadence on the same transition: a gossip burst costs one write per window, and
|
|
@@ -261,6 +269,11 @@ class ContestEngine {
|
|
|
261
269
|
#heartbeatTimer;
|
|
262
270
|
/** The armed (debounced) snapshot-write timer; flushed by `leave()`. See {@link #writeSnapshot}. */
|
|
263
271
|
#snapshotTimer;
|
|
272
|
+
/**
|
|
273
|
+
* Tears down the per-join `subscription-change` re-pull (listener + window timer); set by
|
|
274
|
+
* {@link #armSubscriptionRepull}, cleared by `leave()` or by the window expiring.
|
|
275
|
+
*/
|
|
276
|
+
#subscriptionRepullDisarm;
|
|
264
277
|
/** True when a heartbeat matching our own root was heard this interval (suppression). */
|
|
265
278
|
#heardMatchingRoot = false;
|
|
266
279
|
/** True once we published our record this interval (heartbeat OR divergence response). */
|
|
@@ -271,7 +284,14 @@ class ContestEngine {
|
|
|
271
284
|
this.readOnly = deps.signer === undefined;
|
|
272
285
|
this.#deps = deps;
|
|
273
286
|
this.#criteriaCid = criteriaCidBytes;
|
|
274
|
-
|
|
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
|
+
}));
|
|
275
295
|
// The gating (`rule`) chain fixes the ballot's chainId and the tie-break seed chain.
|
|
276
296
|
const rule = deps.registry[criteria.rule.type];
|
|
277
297
|
if (!rule)
|
|
@@ -688,6 +708,14 @@ class ContestEngine {
|
|
|
688
708
|
* the providers of the criteria CID from the host's HTTP content router. Roots are **unioned,
|
|
689
709
|
* never quorum'd** — a record served by a single peer is still chased, so a colluding majority
|
|
690
710
|
* cannot hide a vote.
|
|
711
|
+
*
|
|
712
|
+
* The `getSubscribers` source is an instantaneous snapshot, and a joiner that dials a seeder
|
|
713
|
+
* and joins immediately (the normal browser boot order) races subscription gossip: at the
|
|
714
|
+
* instant of `join()` it sees zero subscribers, pulls nothing, and would idle until the topic
|
|
715
|
+
* heartbeat (issue #15 — measured 90+ s vs ~4 s). So the pull closure (and its `seen` dedup)
|
|
716
|
+
* stays armed on gossipsub's `subscription-change` for {@link COLD_START_REPULL_WINDOW_MS}:
|
|
717
|
+
* each peer whose subscription to this topic becomes visible inside the window is asked once,
|
|
718
|
+
* closing the race for router-less clients too. See {@link #armSubscriptionRepull}.
|
|
691
719
|
*/
|
|
692
720
|
async #coldStart() {
|
|
693
721
|
const seen = new Set();
|
|
@@ -719,12 +747,45 @@ class ContestEngine {
|
|
|
719
747
|
// live gossip still converge.
|
|
720
748
|
}
|
|
721
749
|
};
|
|
750
|
+
// Arm the re-pull BEFORE the initial fan-out so no subscriber can land in the gap
|
|
751
|
+
// between the snapshot below and the listener; `seen` dedups any overlap.
|
|
752
|
+
this.#armSubscriptionRepull(pull);
|
|
722
753
|
// Shuffle before slicing: a deterministic first-N pick would funnel a whole directory
|
|
723
754
|
// join through the same peers' stream caps while other subscribers idle; a random N
|
|
724
755
|
// spreads contests across the topic's serving peers (see COLD_START_PEER_FETCH_LIMIT).
|
|
725
756
|
const fromSubscribers = shuffled(this.#deps.pubsub.getSubscribers(this.topic)).slice(0, COLD_START_PEERS).map(pull);
|
|
726
757
|
await Promise.allSettled([...fromSubscribers, this.#discoverProviders(pull)]);
|
|
727
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Keep the cold-start pull live on gossipsub's `subscription-change` for one re-pull window:
|
|
761
|
+
* a peer whose subscription to this topic becomes visible after `join()`'s instantaneous
|
|
762
|
+
* `getSubscribers` snapshot is pulled the moment it appears (once — the shared `seen` set
|
|
763
|
+
* dedups), instead of waiting for the heartbeat. Bounded by {@link COLD_START_REPULL_WINDOW_MS}:
|
|
764
|
+
* past the first heartbeat interval the passive heartbeat already covers divergence detection,
|
|
765
|
+
* so a long-lived node does not pay one fetch per churning subscriber forever. Disarmed by
|
|
766
|
+
* `leave()`; a re-join arms a fresh window.
|
|
767
|
+
*/
|
|
768
|
+
#armSubscriptionRepull(pull) {
|
|
769
|
+
this.#disarmSubscriptionRepull(); // a stale listener from a prior join must not leak
|
|
770
|
+
const pubsub = this.#deps.pubsub;
|
|
771
|
+
const listener = (evt) => {
|
|
772
|
+
if (!evt.detail.subscriptions.some((s) => s.topic === this.topic && s.subscribe))
|
|
773
|
+
return;
|
|
774
|
+
void pull(evt.detail.peerId).catch(() => { });
|
|
775
|
+
};
|
|
776
|
+
pubsub.addEventListener("subscription-change", listener);
|
|
777
|
+
const timer = setTimeout(() => this.#disarmSubscriptionRepull(), COLD_START_REPULL_WINDOW_MS);
|
|
778
|
+
// Don't hold a Node process open; no-op in the browser.
|
|
779
|
+
timer.unref?.();
|
|
780
|
+
this.#subscriptionRepullDisarm = () => {
|
|
781
|
+
clearTimeout(timer);
|
|
782
|
+
pubsub.removeEventListener("subscription-change", listener);
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
#disarmSubscriptionRepull() {
|
|
786
|
+
this.#subscriptionRepullDisarm?.();
|
|
787
|
+
this.#subscriptionRepullDisarm = undefined;
|
|
788
|
+
}
|
|
728
789
|
/**
|
|
729
790
|
* Pull one peer's root record over the fetch protocol, retrying a THROWN fetch with full-jittered
|
|
730
791
|
* exponential backoff until {@link COLD_START_FETCH_DEADLINE_MS} (see the constant's note for the
|
|
@@ -821,6 +882,7 @@ class ContestEngine {
|
|
|
821
882
|
}
|
|
822
883
|
// Pause the background verifier's retry timer; pending state survives for a re-join.
|
|
823
884
|
this.#background.stop();
|
|
885
|
+
this.#disarmSubscriptionRepull();
|
|
824
886
|
this.#heardMatchingRoot = false;
|
|
825
887
|
this.#publishedRootThisInterval = false;
|
|
826
888
|
this.#chaser = undefined;
|
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
|
|
@@ -75,6 +75,15 @@ export interface PubsubService {
|
|
|
75
75
|
from?: PeerId;
|
|
76
76
|
};
|
|
77
77
|
}) => void): void;
|
|
78
|
+
/**
|
|
79
|
+
* gossipsub's "a peer's subscription set changed" notification. The cold-start pull
|
|
80
|
+
* re-runs off it (see client/voter.ts `#armSubscriptionRepull`): a joiner that dials a
|
|
81
|
+
* seeder and joins immediately sees zero subscribers at the instant of `join()` — the
|
|
82
|
+
* seeder only appears here once subscription gossip lands — so without this trigger it
|
|
83
|
+
* would idle until the topic heartbeat.
|
|
84
|
+
*/
|
|
85
|
+
addEventListener(type: "subscription-change", listener: SubscriptionChangeListener): void;
|
|
86
|
+
removeEventListener(type: "subscription-change", listener: SubscriptionChangeListener): void;
|
|
78
87
|
/**
|
|
79
88
|
* gossipsub's per-topic validator map. The transport installs the async forward-gate
|
|
80
89
|
* here (`topicValidators.set(topic, gate)`); gossipsub awaits the returned promise
|
|
@@ -85,6 +94,16 @@ export interface PubsubService {
|
|
|
85
94
|
*/
|
|
86
95
|
topicValidators?: Map<string, GossipTopicValidator>;
|
|
87
96
|
}
|
|
97
|
+
/** Listener for gossipsub's `subscription-change` (the libp2p `SubscriptionChangeData` shape). */
|
|
98
|
+
export type SubscriptionChangeListener = (evt: {
|
|
99
|
+
detail: {
|
|
100
|
+
peerId: PeerId;
|
|
101
|
+
subscriptions: Array<{
|
|
102
|
+
topic: string;
|
|
103
|
+
subscribe: boolean;
|
|
104
|
+
}>;
|
|
105
|
+
};
|
|
106
|
+
}) => void;
|
|
88
107
|
/** A received pubsub message, as passed to a gossipsub topic validator. */
|
|
89
108
|
export interface GossipMessage {
|
|
90
109
|
topic: string;
|