@bitsocial/pubsub-voting 0.0.10 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/dist/chain/coalescer.js +4 -0
- package/dist/chain/types.d.ts +18 -6
- package/dist/client/voter.d.ts +32 -9
- package/dist/client/voter.js +116 -17
- package/dist/errors.d.ts +62 -0
- package/dist/errors.js +81 -0
- package/dist/schema/criteria.d.ts +19 -7
- package/dist/schema/criteria.js +18 -4
- package/dist/verify/name-preflight.d.ts +60 -0
- package/dist/verify/name-preflight.js +52 -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)
|
|
@@ -69,7 +81,7 @@ Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingF
|
|
|
69
81
|
```ts
|
|
70
82
|
const contest = await voter.createContest({ criteria }); // criteria: the contest's full document (strictly validated here)
|
|
71
83
|
contest.on("update", () => render(contest.tally)); // tally rides the object; recomputed before each emit
|
|
72
|
-
contest.on("error", (err) => showConnectivityWarning(err)); // tally chain read failed,
|
|
84
|
+
contest.on("error", (err) => showConnectivityWarning(err)); // tally chain read failed, the background verifier's RPC/resolver is down (retrying), or a deferred check evicted THIS wallet's own vote (VoteEvictedError)
|
|
73
85
|
await contest.update(); // join the topic, cold-start, begin emitting
|
|
74
86
|
// const fresh = await contest.getTally(); // or force a fresh read, bypassing the cache
|
|
75
87
|
// await contest.stop(); // leave the topic
|
|
@@ -116,6 +128,20 @@ A community's identity is its `publicKey`. The optional `name` is the community'
|
|
|
116
128
|
|
|
117
129
|
`publish()` on a voter built without a `signer` throws `ReadOnlyError` (and emits an `error`).
|
|
118
130
|
|
|
131
|
+
#### Rejection feedback
|
|
132
|
+
|
|
133
|
+
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:
|
|
134
|
+
|
|
135
|
+
- **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.
|
|
136
|
+
- **After `publish()` resolved**: `"succeeded"` means signed and broadcast, **not** accepted by the network. The deferred checks (the on-chain gate read, and any name resolution a preflight outage skipped) run in the background; if one evicts the bundle, the vote emits a `VoteEvictedError` on its `error` event — carrying the evicted `bundle` and the exact `verdict` any verifier would produce — and its `publishingState` flips to `"failed"` post hoc. The same error fires on the contest's `error` event, for long-lived views.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
vote.on("error", (err) => {
|
|
140
|
+
if (err instanceof VoteEvictedError) console.log(err.verdict.reason); // e.g. "not admitted: rule score is 0n at block …"
|
|
141
|
+
});
|
|
142
|
+
await vote.publish(); // throws InvalidCommunityNameError if a carried name can't back the vote
|
|
143
|
+
```
|
|
144
|
+
|
|
119
145
|
### Republishing is the client's job
|
|
120
146
|
|
|
121
147
|
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:
|
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
|
@@ -41,7 +41,14 @@ export declare function republishIntervalBuckets(criteria: Criteria): number;
|
|
|
41
41
|
* publishes each vote once and the client decides when to refresh (see
|
|
42
42
|
* {@link republishIntervalBuckets} and DESIGN.md "Republishing is the client's job").
|
|
43
43
|
*/
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* A vote publication's lifecycle, walked by {@link ContestVote.publish}. `"succeeded"` means
|
|
46
|
+
* signed, admitted locally, and broadcast — NOT accepted by the network (gossipsub gives a
|
|
47
|
+
* publisher no acceptance/rejection feedback). It can therefore still flip to `"failed"`
|
|
48
|
+
* afterwards: if this node's own deferred checks — the same checks every peer runs — evict the
|
|
49
|
+
* bundle, the vote emits a `VoteEvictedError` and fails post hoc (see DESIGN.md "Background
|
|
50
|
+
* chain verification", publisher feedback).
|
|
51
|
+
*/
|
|
45
52
|
export type PublishingState = "stopped" | "signing" | "publishing" | "succeeded" | "failed";
|
|
46
53
|
/** What {@link ContestVote.publish} resolves: the signed bundle plus a peer-reach hint. */
|
|
47
54
|
export interface PublishOutcome {
|
|
@@ -86,9 +93,11 @@ export interface Contest {
|
|
|
86
93
|
*/
|
|
87
94
|
on(event: "update", cb: () => void): void;
|
|
88
95
|
/**
|
|
89
|
-
* Fired on a contest-level failure: the tally's chain read throws,
|
|
96
|
+
* Fired on a contest-level failure: the tally's chain read throws, the background chain
|
|
90
97
|
* verifier hits an infra-class failure (RPC/resolver down — its bundles stay pending and
|
|
91
|
-
* retry, but the degradation is surfaced here instead of silently stalling)
|
|
98
|
+
* retry, but the degradation is surfaced here instead of silently stalling), or a deferred
|
|
99
|
+
* check evicts THIS wallet's own published vote (`VoteEvictedError` — the same error the
|
|
100
|
+
* publishing `ContestVote` emits; here so a long-lived view hears it too).
|
|
92
101
|
*/
|
|
93
102
|
on(event: "error", cb: (error: unknown) => void): void;
|
|
94
103
|
}
|
|
@@ -110,13 +119,23 @@ export interface ContestVote {
|
|
|
110
119
|
* the `VotesBundle` (whose `blockNumber` the client uses to schedule its own refresh — see
|
|
111
120
|
* {@link republishIntervalBuckets}) plus `recipientCount`, the number of peers gossipsub sent
|
|
112
121
|
* the vote directly to. Emits `publishingstatechange` as it goes; throws (and emits `error`) on
|
|
113
|
-
* failure
|
|
114
|
-
*
|
|
122
|
+
* failure: `ReadOnlyError` with no signer, and `InvalidCommunityNameError` when a vote's
|
|
123
|
+
* carried `community.name` definitively does not resolve to its claimed `publicKey` (checked
|
|
124
|
+
* BEFORE signing or joining — every verifier drops such a bundle silently, so it is refused
|
|
125
|
+
* here instead of published into a network-wide silent drop; a resolver outage does not block
|
|
126
|
+
* the publish). This library does not re-publish: to keep the vote alive, call `publish()`
|
|
127
|
+
* again before it expires.
|
|
115
128
|
*/
|
|
116
129
|
publish(): Promise<PublishOutcome>;
|
|
117
130
|
/** Fired on each `publishingState` transition. */
|
|
118
131
|
on(event: "publishingstatechange", cb: (state: PublishingState) => void): void;
|
|
119
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Fired if publishing fails — including POST HOC, after `publish()` resolved: gossipsub
|
|
134
|
+
* peers reject a bad bundle silently, but this node runs the same deferred checks (gate
|
|
135
|
+
* chain read, name resolution) on its own publish, and if they evict it a `VoteEvictedError`
|
|
136
|
+
* fires here (and `publishingState` flips to `"failed"`) carrying the exact verdict every
|
|
137
|
+
* honest verifier would produce. See DESIGN.md "Background chain verification".
|
|
138
|
+
*/
|
|
120
139
|
on(event: "error", cb: (error: unknown) => void): void;
|
|
121
140
|
}
|
|
122
141
|
/** The factory: one set of injected dependencies, many contests. */
|
|
@@ -172,9 +191,13 @@ export interface PubsubVoterOptions {
|
|
|
172
191
|
*/
|
|
173
192
|
helia: HeliaInstance;
|
|
174
193
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
194
|
+
* Resolves a chain named by a contest's criteria (`requires.chains`, ticker + chainId)
|
|
195
|
+
* to a viem `PublicClient`. Which RPC gateway to use is THIS client's setting — RPC URLs
|
|
196
|
+
* are deliberately not part of the criteria document, so this factory is where the host
|
|
197
|
+
* maps chains to the endpoints it trusts. Return one shared client per chain (memoized),
|
|
198
|
+
* and `undefined` for a chain with no RPC configured — `createContest` /
|
|
199
|
+
* `createContestVote` then throws `MissingChainClientError` (recuse, don't miscount).
|
|
200
|
+
* See `ChainClientFactory` (src/chain/types.ts) for the full contract.
|
|
178
201
|
*/
|
|
179
202
|
chains: ChainClientFactory;
|
|
180
203
|
/** Identity. Omit for a read-only voter (renders tallies, cannot publish). */
|
package/dist/client/voter.js
CHANGED
|
@@ -23,6 +23,7 @@ import { makeAnnouncer } from "../transport/announce/node.js";
|
|
|
23
23
|
import { encode as encodeDagCbor } from "@ipld/dag-cbor";
|
|
24
24
|
import { sha256 } from "viem";
|
|
25
25
|
import { makeBackgroundVerifier } from "../verify/background.js";
|
|
26
|
+
import { preflightCommunityNames } from "../verify/name-preflight.js";
|
|
26
27
|
import { makeAcceptedDedup } from "../transport/accepted-dedup.js";
|
|
27
28
|
import { blockForBytes, decodeCheckpoint, encodeCheckpoint } from "../checkpoint/codec.js";
|
|
28
29
|
import { decodeSnapshot, encodeSnapshot } from "../checkpoint/snapshot.js";
|
|
@@ -30,7 +31,7 @@ import { CID } from "multiformats/cid";
|
|
|
30
31
|
import { makeTally } from "../tally/tally.js";
|
|
31
32
|
import { ballotTypedData } from "../signer/eip712.js";
|
|
32
33
|
import { criteriaCid, TOPIC_PREFIX } from "../topic.js";
|
|
33
|
-
import { ReadOnlyError, UnknownRuleError, VoterDestroyedError } from "../errors.js";
|
|
34
|
+
import { InvalidCommunityNameError, MissingChainClientError, ReadOnlyError, UnknownRuleError, VoteEvictedError, VoterDestroyedError } from "../errors.js";
|
|
34
35
|
/**
|
|
35
36
|
* The recommended cadence, in buckets, at which a client should re-publish a live vote to keep
|
|
36
37
|
* it alive: half its expiry window, rounded up. A bundle is valid for `voteExpiryBuckets` after
|
|
@@ -284,7 +285,14 @@ class ContestEngine {
|
|
|
284
285
|
this.readOnly = deps.signer === undefined;
|
|
285
286
|
this.#deps = deps;
|
|
286
287
|
this.#criteriaCid = criteriaCidBytes;
|
|
287
|
-
|
|
288
|
+
// Resolve every chain the manifest requires, eagerly: a client with no RPC configured
|
|
289
|
+
// for one of them must find out at the create seam (recuse), not on its first verify.
|
|
290
|
+
this.#chainClients = Object.fromEntries(Object.entries(criteria.requires.chains).map(([chain, config]) => {
|
|
291
|
+
const client = deps.chains({ chain, chainId: config.chainId });
|
|
292
|
+
if (client === undefined)
|
|
293
|
+
throw new MissingChainClientError(chain, config.chainId);
|
|
294
|
+
return [chain, client];
|
|
295
|
+
}));
|
|
288
296
|
// The gating (`rule`) chain fixes the ballot's chainId and the tie-break seed chain.
|
|
289
297
|
const rule = deps.registry[criteria.rule.type];
|
|
290
298
|
if (!rule)
|
|
@@ -341,7 +349,7 @@ class ContestEngine {
|
|
|
341
349
|
cache: this.#cache,
|
|
342
350
|
onGateVerified: (cid) => this.#settleCheck(cid, "chainVerified"),
|
|
343
351
|
onNameResolved: (cid) => this.#settleCheck(cid, "nameResolved"),
|
|
344
|
-
onEvict: (cid) => this.#evictBundle(cid),
|
|
352
|
+
onEvict: (cid, verdict) => this.#evictBundle(cid, verdict),
|
|
345
353
|
onError: (error) => this.#emitError(error),
|
|
346
354
|
limit: (fn) => this.#backgroundLimit(fn)
|
|
347
355
|
});
|
|
@@ -375,13 +383,26 @@ class ContestEngine {
|
|
|
375
383
|
* settled bundles (never re-serve what we have not verified).
|
|
376
384
|
*/
|
|
377
385
|
#checks = new Map();
|
|
386
|
+
/**
|
|
387
|
+
* This wallet's own published bundles still awaiting their deferred checks, keyed by CID
|
|
388
|
+
* string — the bundles whose background EVICTION must be reported instead of silent (see
|
|
389
|
+
* {@link #evictBundle} / `VoteEvictedError`; remote evictions are normal operation). An
|
|
390
|
+
* entry is dropped once its checks settle, on evict (after reporting), and on expiry prune.
|
|
391
|
+
*/
|
|
392
|
+
#ownPublishes = new Map();
|
|
393
|
+
/** Per-own-CID eviction callbacks: the publishing `ContestVote` registers one at sign time. */
|
|
394
|
+
#ownEvictionCbs = new Map();
|
|
378
395
|
/** Does any vote in the bundle carry a `community.name` claim (needing resolution)? */
|
|
379
396
|
#carriesName(bundle) {
|
|
380
397
|
return bundle.votes.some((v) => v.community.name !== undefined);
|
|
381
398
|
}
|
|
382
|
-
/**
|
|
383
|
-
|
|
384
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Record a bundle's deferred-check state at admit: fully settled, pending both checks, or —
|
|
401
|
+
* for an own publish whose name preflight already resolved every carried name — pending the
|
|
402
|
+
* gate read only (`nameSettled`).
|
|
403
|
+
*/
|
|
404
|
+
#recordChecks(cid, bundle, settled, nameSettled = settled) {
|
|
405
|
+
this.#checks.set(cid.toString(), this.#carriesName(bundle) ? { chainVerified: settled, nameResolved: nameSettled } : { chainVerified: settled });
|
|
385
406
|
}
|
|
386
407
|
/** The bundle's check state, pessimistic (all pending) if somehow unrecorded. */
|
|
387
408
|
#checksFor(cid, bundle) {
|
|
@@ -404,14 +425,38 @@ class ContestEngine {
|
|
|
404
425
|
if (!checks)
|
|
405
426
|
return; // evicted or pruned while its check was in flight
|
|
406
427
|
checks[key] = true;
|
|
428
|
+
// A fully settled own publish can no longer be evicted — its verdict is terminal — so
|
|
429
|
+
// its eviction-reporting entries are done (see #ownPublishes).
|
|
430
|
+
if (this.#isFullyVerified(cid))
|
|
431
|
+
this.#dropOwnTracking(cid.toString());
|
|
407
432
|
this.#onStateChanged();
|
|
408
433
|
}
|
|
409
|
-
/**
|
|
410
|
-
|
|
434
|
+
/**
|
|
435
|
+
* A deferred check failed: drop the bundle (its verified predecessor, if any, wins again).
|
|
436
|
+
* Evicting this wallet's OWN publish is the one rejection a publisher can ever hear about —
|
|
437
|
+
* gossipsub peers drop a bad bundle silently, but this node runs the same checks (see
|
|
438
|
+
* DESIGN.md "Background chain verification") — so it is reported as a `VoteEvictedError`
|
|
439
|
+
* through the publishing `ContestVote` and the contest `error` event instead of silent.
|
|
440
|
+
*/
|
|
441
|
+
#evictBundle(cid, verdict) {
|
|
411
442
|
this.#crdt.remove(cid);
|
|
412
|
-
|
|
443
|
+
const key = cid.toString();
|
|
444
|
+
this.#checks.delete(key);
|
|
445
|
+
const own = this.#ownPublishes.get(key);
|
|
446
|
+
if (own) {
|
|
447
|
+
const error = new VoteEvictedError(own, verdict);
|
|
448
|
+
const notifyVote = this.#ownEvictionCbs.get(key);
|
|
449
|
+
this.#dropOwnTracking(key);
|
|
450
|
+
notifyVote?.(error);
|
|
451
|
+
this.#emitError(error);
|
|
452
|
+
}
|
|
413
453
|
this.#onStateChanged();
|
|
414
454
|
}
|
|
455
|
+
/** Forget an own publish's eviction-reporting entries (settled, evicted, or expired). */
|
|
456
|
+
#dropOwnTracking(key) {
|
|
457
|
+
this.#ownPublishes.delete(key);
|
|
458
|
+
this.#ownEvictionCbs.delete(key);
|
|
459
|
+
}
|
|
415
460
|
#emitError(error) {
|
|
416
461
|
for (const cb of [...this.#errorListeners])
|
|
417
462
|
cb(error);
|
|
@@ -520,7 +565,9 @@ class ContestEngine {
|
|
|
520
565
|
if (this.#crdt.nodeCount() > 0) {
|
|
521
566
|
await this.#refreshBucket();
|
|
522
567
|
for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
|
|
523
|
-
|
|
568
|
+
const key = removed.toString();
|
|
569
|
+
this.#checks.delete(key);
|
|
570
|
+
this.#dropOwnTracking(key); // expiry is decay, not an eviction — no error
|
|
524
571
|
}
|
|
525
572
|
}
|
|
526
573
|
return this.#tally.compute();
|
|
@@ -890,12 +937,33 @@ class ContestEngine {
|
|
|
890
937
|
if (wasJoined)
|
|
891
938
|
this.#deps.onTopicLeft();
|
|
892
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* Publish-time community-name preflight (see verify/name-preflight.ts): throws
|
|
942
|
+
* `InvalidCommunityNameError` when a carried name definitively fails to resolve to its
|
|
943
|
+
* vote's claimed key — before signing, before the caller joins the topic — because every
|
|
944
|
+
* verifier would silently drop such a bundle. Returns whether every carried name settled
|
|
945
|
+
* (false = a resolver outage skipped one; the background verifier still owns that check).
|
|
946
|
+
*/
|
|
947
|
+
async preflightNames(votes) {
|
|
948
|
+
const result = await preflightCommunityNames({
|
|
949
|
+
votes,
|
|
950
|
+
nameResolvers: this.#deps.nameResolvers,
|
|
951
|
+
cache: this.#deps.nameResolutionCache
|
|
952
|
+
});
|
|
953
|
+
if (!result.ok) {
|
|
954
|
+
throw new InvalidCommunityNameError(result.communityName, result.claimedPublicKey, result.resolvedPublicKey, result.reason);
|
|
955
|
+
}
|
|
956
|
+
return result.settled;
|
|
957
|
+
}
|
|
893
958
|
/**
|
|
894
959
|
* Sign the votes into a bundle for the current bucket boundary block (the block every verifier
|
|
895
960
|
* reads at), add it to the CRDT, and return the bundle plus its encoded block bytes for
|
|
896
|
-
* broadcast. Throws `ReadOnlyError` with no signer.
|
|
961
|
+
* broadcast. Throws `ReadOnlyError` with no signer. `namesSettled` carries the
|
|
962
|
+
* {@link preflightNames} outcome (default false: name checks still owed to the background
|
|
963
|
+
* verifier); `onEvicted` is told if a deferred check later evicts THIS bundle — registered
|
|
964
|
+
* here, before the background verifier can possibly settle, so the report cannot be missed.
|
|
897
965
|
*/
|
|
898
|
-
async signVote(votes) {
|
|
966
|
+
async signVote(votes, opts = {}) {
|
|
899
967
|
const signer = this.#deps.signer;
|
|
900
968
|
if (signer === undefined)
|
|
901
969
|
throw new ReadOnlyError();
|
|
@@ -911,8 +979,12 @@ class ContestEngine {
|
|
|
911
979
|
// Own bundles take the same deferred path as a chased checkpoint's: admitted
|
|
912
980
|
// provisionally, then confirmed (or evicted) by the background gate read — so an
|
|
913
981
|
// ineligible wallet's local tally does not silently disagree with the network's, and
|
|
914
|
-
// our checkpoint never serves a vote we have not verified (even our own).
|
|
915
|
-
|
|
982
|
+
// our checkpoint never serves a vote we have not verified (even our own). A name the
|
|
983
|
+
// preflight already resolved is recorded settled, so it renders verified immediately.
|
|
984
|
+
this.#recordChecks(cid, bundle, false, opts.namesSettled ?? false);
|
|
985
|
+
this.#ownPublishes.set(cid.toString(), bundle);
|
|
986
|
+
if (opts.onEvicted)
|
|
987
|
+
this.#ownEvictionCbs.set(cid.toString(), opts.onEvicted);
|
|
916
988
|
this.#background.enqueue([{ cid, bundle }]);
|
|
917
989
|
this.#onStateChanged();
|
|
918
990
|
return { bundle, encoded: encodeBundle(bundle) };
|
|
@@ -1259,6 +1331,13 @@ class ContestVotePublication {
|
|
|
1259
1331
|
#errorCbs = [];
|
|
1260
1332
|
#state = "stopped";
|
|
1261
1333
|
#bundle;
|
|
1334
|
+
/**
|
|
1335
|
+
* True once the background verifier evicted the current publish's bundle. The eviction can
|
|
1336
|
+
* land WHILE `publish()` is still broadcasting (the deferred checks run concurrently), and
|
|
1337
|
+
* its `"failed"` is terminal for this attempt — the in-flight publish must not stomp it
|
|
1338
|
+
* with `"publishing"`/`"succeeded"`. Reset by the next `publish()` call.
|
|
1339
|
+
*/
|
|
1340
|
+
#evicted = false;
|
|
1262
1341
|
constructor(engine, votes) {
|
|
1263
1342
|
this.#engine = engine;
|
|
1264
1343
|
this.contestId = engine.criteria.contestId;
|
|
@@ -1291,13 +1370,33 @@ class ContestVotePublication {
|
|
|
1291
1370
|
throw error;
|
|
1292
1371
|
}
|
|
1293
1372
|
try {
|
|
1373
|
+
// Name preflight, also before joining: a vote whose carried community name
|
|
1374
|
+
// definitively fails to resolve to its claimed key would be silently dropped by
|
|
1375
|
+
// every verifier, so it is refused here (`InvalidCommunityNameError`) instead of
|
|
1376
|
+
// broadcast. A resolver outage does not block the publish (namesSettled: false —
|
|
1377
|
+
// the background verifier owns the deferred check).
|
|
1378
|
+
const namesSettled = await this.#engine.preflightNames(this.votes);
|
|
1294
1379
|
await this.#engine.join();
|
|
1380
|
+
this.#evicted = false;
|
|
1295
1381
|
this.#setState("signing");
|
|
1296
|
-
const { bundle, encoded } = await this.#engine.signVote([...this.votes]
|
|
1382
|
+
const { bundle, encoded } = await this.#engine.signVote([...this.votes], {
|
|
1383
|
+
namesSettled,
|
|
1384
|
+
// The one rejection a publisher can hear about (peers drop silently): our own
|
|
1385
|
+
// node's deferred checks evicting this bundle. Usually post hoc — publish() has
|
|
1386
|
+
// already resolved — so it surfaces as `error` + `publishingState: "failed"`.
|
|
1387
|
+
onEvicted: (error) => {
|
|
1388
|
+
this.#evicted = true;
|
|
1389
|
+
this.#fail(error);
|
|
1390
|
+
}
|
|
1391
|
+
});
|
|
1297
1392
|
this.#bundle = bundle;
|
|
1298
|
-
this.#
|
|
1393
|
+
if (!this.#evicted)
|
|
1394
|
+
this.#setState("publishing");
|
|
1299
1395
|
const { recipientCount } = await this.#engine.broadcastBundle(encoded);
|
|
1300
|
-
this
|
|
1396
|
+
// An eviction that landed mid-broadcast already failed this attempt; the outcome
|
|
1397
|
+
// still resolves (the bundle DID hit the wire) but the state stays "failed".
|
|
1398
|
+
if (!this.#evicted)
|
|
1399
|
+
this.#setState("succeeded");
|
|
1301
1400
|
return { bundle, recipientCount };
|
|
1302
1401
|
}
|
|
1303
1402
|
catch (error) {
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { VotesBundle } from "./schema/votes.js";
|
|
2
|
+
import type { VerifyFail } from "./verify/types.js";
|
|
1
3
|
/**
|
|
2
4
|
* Library error types.
|
|
3
5
|
*
|
|
@@ -22,6 +24,18 @@ export declare class UnknownRuleError extends Error {
|
|
|
22
24
|
readonly type: string;
|
|
23
25
|
constructor(slot: "rule" | "weight" | "requires", type: string);
|
|
24
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Thrown by `createContest` / `createContestVote` when the criteria's dependency manifest
|
|
29
|
+
* names a chain (`requires.chains`) the host's `ChainClientFactory` cannot resolve to a
|
|
30
|
+
* client (it returned `undefined`). RPC endpoints are client-local settings, not part of
|
|
31
|
+
* the criteria document, so a client with no gateway configured for a required chain must
|
|
32
|
+
* recuse the contest rather than miscount — the chain-side twin of `UnknownRuleError`.
|
|
33
|
+
*/
|
|
34
|
+
export declare class MissingChainClientError extends Error {
|
|
35
|
+
readonly chain: string;
|
|
36
|
+
readonly chainId: number;
|
|
37
|
+
constructor(chain: string, chainId: number);
|
|
38
|
+
}
|
|
25
39
|
/**
|
|
26
40
|
* Thrown at construction when the injected Helia node's libp2p has no usable pubsub
|
|
27
41
|
* (gossipsub) service at `libp2p.services.pubsub`. The library broadcasts and receives
|
|
@@ -79,3 +93,51 @@ export declare class DuplicateContestIdError extends Error {
|
|
|
79
93
|
export declare class ReadOnlyError extends Error {
|
|
80
94
|
constructor();
|
|
81
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
|
|
98
|
+
* fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
|
|
99
|
+
* handles it, it resolves to no record, or it resolves to a DIFFERENT key than the vote
|
|
100
|
+
* claims. Every honest verifier runs the same check and drops such a bundle without telling
|
|
101
|
+
* the publisher (gossipsub has no rejection feedback), so the vote is refused here — before
|
|
102
|
+
* signing, before joining the topic — instead of being published into a silent network-wide
|
|
103
|
+
* drop. Fix the name (or the claimed `publicKey`) and publish again. A resolver that merely
|
|
104
|
+
* THREW (registry outage) does not throw this: the vote publishes and the background verifier
|
|
105
|
+
* settles the check, surfacing a `VoteEvictedError` if it turns out bad.
|
|
106
|
+
*/
|
|
107
|
+
export declare class InvalidCommunityNameError extends Error {
|
|
108
|
+
/** The offending `community.name`. */
|
|
109
|
+
readonly communityName: string;
|
|
110
|
+
/** The `community.publicKey` the vote claims the name points at. */
|
|
111
|
+
readonly claimedPublicKey: string;
|
|
112
|
+
/** What the registry resolved the name to; `undefined` for no-resolver / no-record. */
|
|
113
|
+
readonly resolvedPublicKey: string | undefined;
|
|
114
|
+
constructor(
|
|
115
|
+
/** The offending `community.name`. */
|
|
116
|
+
communityName: string,
|
|
117
|
+
/** The `community.publicKey` the vote claims the name points at. */
|
|
118
|
+
claimedPublicKey: string,
|
|
119
|
+
/** What the registry resolved the name to; `undefined` for no-resolver / no-record. */
|
|
120
|
+
resolvedPublicKey: string | undefined, reason: string);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Emitted (never thrown) when a deferred network check EVICTS this wallet's own published
|
|
124
|
+
* vote: the background verifier read the gate rule as `0n` at the vote's sample block, or its
|
|
125
|
+
* carried community name did not check out (see DESIGN.md "Background chain verification").
|
|
126
|
+
* `publish()` resolves on the offline checks, so an own vote that fails a deferred check
|
|
127
|
+
* would otherwise just silently vanish from the local tally — while every honest peer,
|
|
128
|
+
* running the same checks, drops it with no feedback (gossipsub has no rejection channel).
|
|
129
|
+
* This error is that missing feedback, built from the local verdict: it fires on the
|
|
130
|
+
* publishing `ContestVote`'s `error` event (flipping its `publishingState` to `"failed"`)
|
|
131
|
+
* and on the contest's `error` event. Carries the evicted bundle and the exact verdict.
|
|
132
|
+
*/
|
|
133
|
+
export declare class VoteEvictedError extends Error {
|
|
134
|
+
/** The signed bundle that was evicted (the one `publish()` resolved with). */
|
|
135
|
+
readonly bundle: VotesBundle;
|
|
136
|
+
/** The failing verdict, with the same `reason` wording every verifier produces. */
|
|
137
|
+
readonly verdict: VerifyFail;
|
|
138
|
+
constructor(
|
|
139
|
+
/** The signed bundle that was evicted (the one `publish()` resolved with). */
|
|
140
|
+
bundle: VotesBundle,
|
|
141
|
+
/** The failing verdict, with the same `reason` wording every verifier produces. */
|
|
142
|
+
verdict: VerifyFail);
|
|
143
|
+
}
|
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
|
|
@@ -124,3 +144,64 @@ export class ReadOnlyError extends Error {
|
|
|
124
144
|
this.name = "ReadOnlyError";
|
|
125
145
|
}
|
|
126
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Thrown by `ContestVote.publish()` when a vote carries a `community.name` that definitively
|
|
149
|
+
* fails the publish-time preflight (see verify/name-preflight.ts): no configured resolver
|
|
150
|
+
* handles it, it resolves to no record, or it resolves to a DIFFERENT key than the vote
|
|
151
|
+
* claims. Every honest verifier runs the same check and drops such a bundle without telling
|
|
152
|
+
* the publisher (gossipsub has no rejection feedback), so the vote is refused here — before
|
|
153
|
+
* signing, before joining the topic — instead of being published into a silent network-wide
|
|
154
|
+
* drop. Fix the name (or the claimed `publicKey`) and publish again. A resolver that merely
|
|
155
|
+
* THREW (registry outage) does not throw this: the vote publishes and the background verifier
|
|
156
|
+
* settles the check, surfacing a `VoteEvictedError` if it turns out bad.
|
|
157
|
+
*/
|
|
158
|
+
export class InvalidCommunityNameError extends Error {
|
|
159
|
+
communityName;
|
|
160
|
+
claimedPublicKey;
|
|
161
|
+
resolvedPublicKey;
|
|
162
|
+
constructor(
|
|
163
|
+
/** The offending `community.name`. */
|
|
164
|
+
communityName,
|
|
165
|
+
/** The `community.publicKey` the vote claims the name points at. */
|
|
166
|
+
claimedPublicKey,
|
|
167
|
+
/** What the registry resolved the name to; `undefined` for no-resolver / no-record. */
|
|
168
|
+
resolvedPublicKey, reason) {
|
|
169
|
+
super(`Cannot publish this vote: ${reason}. Every verifier checks a carried community ` +
|
|
170
|
+
`name against its registry and silently drops a bundle whose name does not ` +
|
|
171
|
+
`resolve to the claimed publicKey, so this vote would never be counted. Fix ` +
|
|
172
|
+
`the name (or the claimed publicKey), or drop the name from the vote, and ` +
|
|
173
|
+
`publish again.`);
|
|
174
|
+
this.communityName = communityName;
|
|
175
|
+
this.claimedPublicKey = claimedPublicKey;
|
|
176
|
+
this.resolvedPublicKey = resolvedPublicKey;
|
|
177
|
+
this.name = "InvalidCommunityNameError";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Emitted (never thrown) when a deferred network check EVICTS this wallet's own published
|
|
182
|
+
* vote: the background verifier read the gate rule as `0n` at the vote's sample block, or its
|
|
183
|
+
* carried community name did not check out (see DESIGN.md "Background chain verification").
|
|
184
|
+
* `publish()` resolves on the offline checks, so an own vote that fails a deferred check
|
|
185
|
+
* would otherwise just silently vanish from the local tally — while every honest peer,
|
|
186
|
+
* running the same checks, drops it with no feedback (gossipsub has no rejection channel).
|
|
187
|
+
* This error is that missing feedback, built from the local verdict: it fires on the
|
|
188
|
+
* publishing `ContestVote`'s `error` event (flipping its `publishingState` to `"failed"`)
|
|
189
|
+
* and on the contest's `error` event. Carries the evicted bundle and the exact verdict.
|
|
190
|
+
*/
|
|
191
|
+
export class VoteEvictedError extends Error {
|
|
192
|
+
bundle;
|
|
193
|
+
verdict;
|
|
194
|
+
constructor(
|
|
195
|
+
/** The signed bundle that was evicted (the one `publish()` resolved with). */
|
|
196
|
+
bundle,
|
|
197
|
+
/** The failing verdict, with the same `reason` wording every verifier produces. */
|
|
198
|
+
verdict) {
|
|
199
|
+
super(`This wallet's published vote failed a deferred verification check and was ` +
|
|
200
|
+
`evicted from the local tally: ${verdict.reason}. Honest peers run the same ` +
|
|
201
|
+
`checks, so the network will not count this vote either. Fix the cause and ` +
|
|
202
|
+
`publish again.`);
|
|
203
|
+
this.bundle = bundle;
|
|
204
|
+
this.verdict = verdict;
|
|
205
|
+
this.name = "VoteEvictedError";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -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
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Vote } from "../schema/votes.js";
|
|
2
|
+
import type { NameResolver } from "../chain/types.js";
|
|
3
|
+
import { type NameResolutionCache } from "./name-resolution-cache.js";
|
|
4
|
+
/**
|
|
5
|
+
* Publish-time community-name preflight — the publisher-side twin of the verify pipeline's
|
|
6
|
+
* step 4 (see bundle.ts). Gossipsub has no rejection-feedback channel: a peer that drops a
|
|
7
|
+
* bundle never tells the publisher why (or that it dropped it at all), so the only "clear
|
|
8
|
+
* error" a publisher can ever get is from running the same checks locally BEFORE the vote
|
|
9
|
+
* hits the wire. A vote naming a community whose name does not check out is guaranteed to be
|
|
10
|
+
* dropped by every honest verifier, so publishing it is pure waste; failing fast here turns
|
|
11
|
+
* that silent network-wide drop into an immediate, explainable publish error.
|
|
12
|
+
*
|
|
13
|
+
* The failure split mirrors the pipeline's `reject`/`ignore` philosophy, adapted to the
|
|
14
|
+
* publisher (who, unlike a relayer, can always just fix the vote and retry):
|
|
15
|
+
* - definitive from this node's view — no resolver handles the TLD, the name has no record,
|
|
16
|
+
* or it resolves to a DIFFERENT key than the vote claims — fails the preflight; every
|
|
17
|
+
* verifier sharing this node's view would drop the bundle the same way.
|
|
18
|
+
* - transient — the resolver THREW (registry RPC down) — passes the preflight with
|
|
19
|
+
* `settled: false`: a resolver outage must not block voting (the same reason the
|
|
20
|
+
* background verifier retries instead of evicting on a throw), and the deferred check
|
|
21
|
+
* settles or evicts the bundle once the resolver recovers.
|
|
22
|
+
*
|
|
23
|
+
* Resolutions ride the shared {@link NameResolutionCache} (the pkc-js rule, 1-hour max-age),
|
|
24
|
+
* so a preflight-resolved name is a cache hit for the background verifier's own pass — the
|
|
25
|
+
* preflight adds at most one live registry read per name per hour, not a second read path.
|
|
26
|
+
*/
|
|
27
|
+
/** One name that definitively failed the preflight (the first failure aborts the scan). */
|
|
28
|
+
export interface NamePreflightFailure {
|
|
29
|
+
ok: false;
|
|
30
|
+
communityName: string;
|
|
31
|
+
/** The `community.publicKey` the vote claims the name points at. */
|
|
32
|
+
claimedPublicKey: string;
|
|
33
|
+
/** What the registry actually resolved the name to; absent for no-resolver / no-record. */
|
|
34
|
+
resolvedPublicKey?: string;
|
|
35
|
+
/** Human-readable cause, same wording as the verify pipeline's step-4 verdicts. */
|
|
36
|
+
reason: string;
|
|
37
|
+
}
|
|
38
|
+
export type NamePreflightResult = {
|
|
39
|
+
ok: true;
|
|
40
|
+
/**
|
|
41
|
+
* True when every carried name resolved to its claimed key right now; false when at
|
|
42
|
+
* least one resolution was SKIPPED on a resolver throw (transient outage) and is
|
|
43
|
+
* still owed to the background verifier. Feeds the publisher's own
|
|
44
|
+
* `nameResolved` check state, so a preflight-settled vote renders verified
|
|
45
|
+
* immediately instead of flashing a pending row.
|
|
46
|
+
*/
|
|
47
|
+
settled: boolean;
|
|
48
|
+
} | NamePreflightFailure;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve every distinct `community.name` carried by `votes` and check each against its
|
|
51
|
+
* vote's claimed `publicKey`. Returns the first definitive failure, or `ok` with whether
|
|
52
|
+
* every name settled (see module doc for the definitive/transient split). Votes carrying no
|
|
53
|
+
* name are free: no resolver is consulted and `{ ok: true, settled: true }` returns
|
|
54
|
+
* synchronously.
|
|
55
|
+
*/
|
|
56
|
+
export declare function preflightCommunityNames(opts: {
|
|
57
|
+
votes: readonly Vote[];
|
|
58
|
+
nameResolvers: NameResolver[];
|
|
59
|
+
cache: NameResolutionCache | undefined;
|
|
60
|
+
}): Promise<NamePreflightResult>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { resolveNameThroughCache } from "./name-resolution-cache.js";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve every distinct `community.name` carried by `votes` and check each against its
|
|
4
|
+
* vote's claimed `publicKey`. Returns the first definitive failure, or `ok` with whether
|
|
5
|
+
* every name settled (see module doc for the definitive/transient split). Votes carrying no
|
|
6
|
+
* name are free: no resolver is consulted and `{ ok: true, settled: true }` returns
|
|
7
|
+
* synchronously.
|
|
8
|
+
*/
|
|
9
|
+
export async function preflightCommunityNames(opts) {
|
|
10
|
+
const { votes, nameResolvers, cache } = opts;
|
|
11
|
+
let settled = true;
|
|
12
|
+
for (const v of votes) {
|
|
13
|
+
const name = v.community.name;
|
|
14
|
+
if (!name)
|
|
15
|
+
continue;
|
|
16
|
+
const resolver = nameResolvers.find((r) => r.canResolve({ name }));
|
|
17
|
+
if (!resolver) {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
communityName: name,
|
|
21
|
+
claimedPublicKey: v.community.publicKey,
|
|
22
|
+
reason: `no resolver handles community name "${name}"`
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
let record;
|
|
26
|
+
try {
|
|
27
|
+
record = await resolveNameThroughCache({ resolver, name, cache });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
settled = false; // transient registry outage — never blocks a publish
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!record) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
communityName: name,
|
|
37
|
+
claimedPublicKey: v.community.publicKey,
|
|
38
|
+
reason: `community name "${name}" does not resolve`
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (record.publicKey !== v.community.publicKey) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
communityName: name,
|
|
45
|
+
claimedPublicKey: v.community.publicKey,
|
|
46
|
+
resolvedPublicKey: record.publicKey,
|
|
47
|
+
reason: `community name "${name}" resolves to ${record.publicKey}, not the claimed ${v.community.publicKey}`
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, settled };
|
|
52
|
+
}
|