@bitsocial/pubsub-voting 0.1.0 → 0.1.2
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 +15 -1
- package/dist/client/voter.d.ts +25 -6
- package/dist/client/voter.js +108 -16
- package/dist/errors.d.ts +50 -0
- package/dist/errors.js +61 -0
- package/dist/transport/integration/harness.d.ts +8 -2
- package/dist/transport/integration/harness.js +20 -2
- 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
|
@@ -81,7 +81,7 @@ Construction throws `MissingPubsubError`, `MissingBlockstoreError`, or `MissingF
|
|
|
81
81
|
```ts
|
|
82
82
|
const contest = await voter.createContest({ criteria }); // criteria: the contest's full document (strictly validated here)
|
|
83
83
|
contest.on("update", () => render(contest.tally)); // tally rides the object; recomputed before each emit
|
|
84
|
-
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)
|
|
85
85
|
await contest.update(); // join the topic, cold-start, begin emitting
|
|
86
86
|
// const fresh = await contest.getTally(); // or force a fresh read, bypassing the cache
|
|
87
87
|
// await contest.stop(); // leave the topic
|
|
@@ -128,6 +128,20 @@ A community's identity is its `publicKey`. The optional `name` is the community'
|
|
|
128
128
|
|
|
129
129
|
`publish()` on a voter built without a `signer` throws `ReadOnlyError` (and emits an `error`).
|
|
130
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
|
+
|
|
131
145
|
### Republishing is the client's job
|
|
132
146
|
|
|
133
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/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. */
|
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 { MissingChainClientError, 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
|
|
@@ -348,7 +349,7 @@ class ContestEngine {
|
|
|
348
349
|
cache: this.#cache,
|
|
349
350
|
onGateVerified: (cid) => this.#settleCheck(cid, "chainVerified"),
|
|
350
351
|
onNameResolved: (cid) => this.#settleCheck(cid, "nameResolved"),
|
|
351
|
-
onEvict: (cid) => this.#evictBundle(cid),
|
|
352
|
+
onEvict: (cid, verdict) => this.#evictBundle(cid, verdict),
|
|
352
353
|
onError: (error) => this.#emitError(error),
|
|
353
354
|
limit: (fn) => this.#backgroundLimit(fn)
|
|
354
355
|
});
|
|
@@ -382,13 +383,26 @@ class ContestEngine {
|
|
|
382
383
|
* settled bundles (never re-serve what we have not verified).
|
|
383
384
|
*/
|
|
384
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();
|
|
385
395
|
/** Does any vote in the bundle carry a `community.name` claim (needing resolution)? */
|
|
386
396
|
#carriesName(bundle) {
|
|
387
397
|
return bundle.votes.some((v) => v.community.name !== undefined);
|
|
388
398
|
}
|
|
389
|
-
/**
|
|
390
|
-
|
|
391
|
-
|
|
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 });
|
|
392
406
|
}
|
|
393
407
|
/** The bundle's check state, pessimistic (all pending) if somehow unrecorded. */
|
|
394
408
|
#checksFor(cid, bundle) {
|
|
@@ -411,14 +425,38 @@ class ContestEngine {
|
|
|
411
425
|
if (!checks)
|
|
412
426
|
return; // evicted or pruned while its check was in flight
|
|
413
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());
|
|
414
432
|
this.#onStateChanged();
|
|
415
433
|
}
|
|
416
|
-
/**
|
|
417
|
-
|
|
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) {
|
|
418
442
|
this.#crdt.remove(cid);
|
|
419
|
-
|
|
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
|
+
}
|
|
420
453
|
this.#onStateChanged();
|
|
421
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
|
+
}
|
|
422
460
|
#emitError(error) {
|
|
423
461
|
for (const cb of [...this.#errorListeners])
|
|
424
462
|
cb(error);
|
|
@@ -527,7 +565,9 @@ class ContestEngine {
|
|
|
527
565
|
if (this.#crdt.nodeCount() > 0) {
|
|
528
566
|
await this.#refreshBucket();
|
|
529
567
|
for (const removed of await this.#crdt.prune(this.#currentBucketCache)) {
|
|
530
|
-
|
|
568
|
+
const key = removed.toString();
|
|
569
|
+
this.#checks.delete(key);
|
|
570
|
+
this.#dropOwnTracking(key); // expiry is decay, not an eviction — no error
|
|
531
571
|
}
|
|
532
572
|
}
|
|
533
573
|
return this.#tally.compute();
|
|
@@ -897,12 +937,33 @@ class ContestEngine {
|
|
|
897
937
|
if (wasJoined)
|
|
898
938
|
this.#deps.onTopicLeft();
|
|
899
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
|
+
}
|
|
900
958
|
/**
|
|
901
959
|
* Sign the votes into a bundle for the current bucket boundary block (the block every verifier
|
|
902
960
|
* reads at), add it to the CRDT, and return the bundle plus its encoded block bytes for
|
|
903
|
-
* 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.
|
|
904
965
|
*/
|
|
905
|
-
async signVote(votes) {
|
|
966
|
+
async signVote(votes, opts = {}) {
|
|
906
967
|
const signer = this.#deps.signer;
|
|
907
968
|
if (signer === undefined)
|
|
908
969
|
throw new ReadOnlyError();
|
|
@@ -918,8 +979,12 @@ class ContestEngine {
|
|
|
918
979
|
// Own bundles take the same deferred path as a chased checkpoint's: admitted
|
|
919
980
|
// provisionally, then confirmed (or evicted) by the background gate read — so an
|
|
920
981
|
// ineligible wallet's local tally does not silently disagree with the network's, and
|
|
921
|
-
// our checkpoint never serves a vote we have not verified (even our own).
|
|
922
|
-
|
|
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);
|
|
923
988
|
this.#background.enqueue([{ cid, bundle }]);
|
|
924
989
|
this.#onStateChanged();
|
|
925
990
|
return { bundle, encoded: encodeBundle(bundle) };
|
|
@@ -1266,6 +1331,13 @@ class ContestVotePublication {
|
|
|
1266
1331
|
#errorCbs = [];
|
|
1267
1332
|
#state = "stopped";
|
|
1268
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;
|
|
1269
1341
|
constructor(engine, votes) {
|
|
1270
1342
|
this.#engine = engine;
|
|
1271
1343
|
this.contestId = engine.criteria.contestId;
|
|
@@ -1298,13 +1370,33 @@ class ContestVotePublication {
|
|
|
1298
1370
|
throw error;
|
|
1299
1371
|
}
|
|
1300
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);
|
|
1301
1379
|
await this.#engine.join();
|
|
1380
|
+
this.#evicted = false;
|
|
1302
1381
|
this.#setState("signing");
|
|
1303
|
-
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
|
+
});
|
|
1304
1392
|
this.#bundle = bundle;
|
|
1305
|
-
this.#
|
|
1393
|
+
if (!this.#evicted)
|
|
1394
|
+
this.#setState("publishing");
|
|
1306
1395
|
const { recipientCount } = await this.#engine.broadcastBundle(encoded);
|
|
1307
|
-
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");
|
|
1308
1400
|
return { bundle, recipientCount };
|
|
1309
1401
|
}
|
|
1310
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
|
*
|
|
@@ -91,3 +93,51 @@ export declare class DuplicateContestIdError extends Error {
|
|
|
91
93
|
export declare class ReadOnlyError extends Error {
|
|
92
94
|
constructor();
|
|
93
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
|
@@ -144,3 +144,64 @@ export class ReadOnlyError extends Error {
|
|
|
144
144
|
this.name = "ReadOnlyError";
|
|
145
145
|
}
|
|
146
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
|
+
}
|
|
@@ -6,7 +6,7 @@ import type { VoteCrdt } from "../../crdt/types.js";
|
|
|
6
6
|
import { type VerdictCache } from "../../verify/cache.js";
|
|
7
7
|
import { type RootChaser } from "../chase.js";
|
|
8
8
|
import type { PubsubService, VoteTransport } from "../types.js";
|
|
9
|
-
import { type RootRecord } from "../messages.js";
|
|
9
|
+
import { type FetchRootRecord, type RootRecord } from "../messages.js";
|
|
10
10
|
/** The gossipsub peer-score methods used for assertions — on the concrete class, not the interface. */
|
|
11
11
|
interface ScoreOps {
|
|
12
12
|
getScore(peer: string): number;
|
|
@@ -41,7 +41,13 @@ export interface VoteNode {
|
|
|
41
41
|
/** Replace the injected verifier (e.g. a rejecter, or a slow one, for a specific assertion). */
|
|
42
42
|
setVerifier(verify: (bundle: VotesBundle) => Promise<BundleVerdict>): void;
|
|
43
43
|
/** Encode this node's current winner-set to a checkpoint (blocks written to its blockstore). */
|
|
44
|
-
checkpointRootRecord(): Promise<
|
|
44
|
+
checkpointRootRecord(): Promise<FetchRootRecord>;
|
|
45
|
+
/**
|
|
46
|
+
* Pull `from`'s root record over the REAL libp2p fetch protocol — the cold-start pull a
|
|
47
|
+
* joiner makes before chasing (voter.ts `#fetchRootWithRetry`). `undefined` when the peer
|
|
48
|
+
* serves nothing for the key.
|
|
49
|
+
*/
|
|
50
|
+
fetchRootRecord(from: VoteNode): Promise<FetchRootRecord | undefined>;
|
|
45
51
|
/** Publish this node's own root record on the topic (a heartbeat). */
|
|
46
52
|
publishOwnRoot(): Promise<void>;
|
|
47
53
|
/** Seed a bundle straight into this node's state (store block + CRDT merge), no network. */
|
|
@@ -17,7 +17,7 @@ import { encodeCheckpoint } from "../../checkpoint/codec.js";
|
|
|
17
17
|
import { makeGossipGate } from "../gossip-validator.js";
|
|
18
18
|
import { makeRootChaser, toChaseSession } from "../chase.js";
|
|
19
19
|
import { makeVoteTransport } from "../transport.js";
|
|
20
|
-
import { decodeVoteMessage, maxBundleMessageBytes, MAX_ROOT_MESSAGE_BYTES, ROOT_RECORD_VERSION } from "../messages.js";
|
|
20
|
+
import { decodeVoteMessage, decodeRootRecord, encodeRootRecord, maxBundleMessageBytes, rootFetchKey, MAX_ROOT_MESSAGE_BYTES, ROOT_RECORD_VERSION } from "../messages.js";
|
|
21
21
|
/**
|
|
22
22
|
* Test harness for the two-node gossipsub integration test. It stands up ONE real libp2p +
|
|
23
23
|
* Helia node carrying `@libp2p/gossipsub` (>= 15.0.23, the CVE-2026-46679 floor) and
|
|
@@ -117,16 +117,30 @@ export async function makeVoteNode(topic, options = {}) {
|
|
|
117
117
|
let matchedOwnRoot = false;
|
|
118
118
|
async function checkpointRootRecord() {
|
|
119
119
|
const winners = crdt.current(CURRENT_BUCKET);
|
|
120
|
-
const { root, blocks } = await encodeCheckpoint(winners);
|
|
120
|
+
const { root, chunks, blocks } = await encodeCheckpoint(winners);
|
|
121
121
|
for (const block of blocks)
|
|
122
122
|
await blockstore.put(block.cid, block.bytes);
|
|
123
123
|
return {
|
|
124
124
|
version: ROOT_RECORD_VERSION,
|
|
125
125
|
root,
|
|
126
|
+
chunks,
|
|
126
127
|
count: winners.length,
|
|
127
128
|
sizeBytes: blocks.reduce((total, block) => total + block.bytes.length, 0)
|
|
128
129
|
};
|
|
129
130
|
}
|
|
131
|
+
// Mirror the production fetch responder (voter.ts `#rootLookup`): answer `<topic>/root` with
|
|
132
|
+
// this node's current root record, encoded on demand — the surface a cold joiner pulls
|
|
133
|
+
// before chasing. `@libp2p/fetch` hands the lookup the requested key as raw bytes.
|
|
134
|
+
libp2p.services.fetch.registerLookupFunction(topic, async (keyBytes) => {
|
|
135
|
+
if (new TextDecoder().decode(keyBytes) !== rootFetchKey(topic))
|
|
136
|
+
return undefined;
|
|
137
|
+
try {
|
|
138
|
+
return encodeRootRecord(await checkpointRootRecord());
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
130
144
|
const openedSessions = [];
|
|
131
145
|
const chaseLimit = pLimit(2);
|
|
132
146
|
const chaser = makeRootChaser({
|
|
@@ -228,6 +242,10 @@ export async function makeVoteNode(topic, options = {}) {
|
|
|
228
242
|
verifyImpl = verify;
|
|
229
243
|
},
|
|
230
244
|
checkpointRootRecord,
|
|
245
|
+
fetchRootRecord: async (from) => {
|
|
246
|
+
const bytes = await libp2p.services.fetch.fetch(from.libp2p.peerId, rootFetchKey(topic));
|
|
247
|
+
return bytes == null ? undefined : decodeRootRecord(bytes);
|
|
248
|
+
},
|
|
231
249
|
publishOwnRoot: async () => {
|
|
232
250
|
await transport.publishRootRecord(await checkpointRootRecord());
|
|
233
251
|
},
|
|
@@ -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
|
+
}
|