@bitsocial/pubsub-voting 0.0.6
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/LICENSE +674 -0
- package/README.md +223 -0
- package/dist/chain/bucket.d.ts +13 -0
- package/dist/chain/bucket.js +24 -0
- package/dist/chain/coalescer.d.ts +56 -0
- package/dist/chain/coalescer.js +217 -0
- package/dist/chain/ticker.d.ts +15 -0
- package/dist/chain/ticker.js +25 -0
- package/dist/chain/types.d.ts +90 -0
- package/dist/chain/types.js +1 -0
- package/dist/checkpoint/codec.d.ts +54 -0
- package/dist/checkpoint/codec.js +99 -0
- package/dist/client/root-puller.d.ts +49 -0
- package/dist/client/root-puller.js +140 -0
- package/dist/client/voter.d.ts +239 -0
- package/dist/client/voter.js +1343 -0
- package/dist/crdt/codec.d.ts +41 -0
- package/dist/crdt/codec.js +137 -0
- package/dist/crdt/crdt.d.ts +22 -0
- package/dist/crdt/crdt.js +127 -0
- package/dist/crdt/store.d.ts +8 -0
- package/dist/crdt/store.js +23 -0
- package/dist/crdt/types.d.ts +87 -0
- package/dist/crdt/types.js +1 -0
- package/dist/encoding/canonical.d.ts +22 -0
- package/dist/encoding/canonical.js +26 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.js +126 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/rules/constant.d.ts +14 -0
- package/dist/rules/constant.js +18 -0
- package/dist/rules/erc20-balance.d.ts +30 -0
- package/dist/rules/erc20-balance.js +44 -0
- package/dist/rules/erc721-min-balance.d.ts +18 -0
- package/dist/rules/erc721-min-balance.js +100 -0
- package/dist/rules/registry.d.ts +42 -0
- package/dist/rules/registry.js +61 -0
- package/dist/rules/types.d.ts +74 -0
- package/dist/rules/types.js +1 -0
- package/dist/schema/common.d.ts +25 -0
- package/dist/schema/common.js +24 -0
- package/dist/schema/criteria.d.ts +78 -0
- package/dist/schema/criteria.js +76 -0
- package/dist/schema/directory.d.ts +42 -0
- package/dist/schema/directory.js +52 -0
- package/dist/schema/votes.d.ts +55 -0
- package/dist/schema/votes.js +116 -0
- package/dist/signer/eip712.d.ts +103 -0
- package/dist/signer/eip712.js +85 -0
- package/dist/signer/types.d.ts +31 -0
- package/dist/signer/types.js +1 -0
- package/dist/storage/browser.d.ts +3 -0
- package/dist/storage/browser.js +110 -0
- package/dist/storage/memory.d.ts +10 -0
- package/dist/storage/memory.js +56 -0
- package/dist/storage/node.d.ts +5 -0
- package/dist/storage/node.js +106 -0
- package/dist/storage/types.d.ts +46 -0
- package/dist/storage/types.js +1 -0
- package/dist/store/indexeddb.d.ts +9 -0
- package/dist/store/indexeddb.js +72 -0
- package/dist/store/memory.d.ts +15 -0
- package/dist/store/memory.js +22 -0
- package/dist/store/select.d.ts +15 -0
- package/dist/store/select.js +64 -0
- package/dist/store/sqlite.d.ts +11 -0
- package/dist/store/sqlite.js +68 -0
- package/dist/store/types.d.ts +57 -0
- package/dist/store/types.js +1 -0
- package/dist/tally/tally.d.ts +44 -0
- package/dist/tally/tally.js +89 -0
- package/dist/tally/types.d.ts +51 -0
- package/dist/tally/types.js +13 -0
- package/dist/topic.d.ts +20 -0
- package/dist/topic.js +28 -0
- package/dist/transport/accepted-dedup.d.ts +30 -0
- package/dist/transport/accepted-dedup.js +34 -0
- package/dist/transport/announce/browser.d.ts +9 -0
- package/dist/transport/announce/browser.js +14 -0
- package/dist/transport/announce/node.d.ts +38 -0
- package/dist/transport/announce/node.js +162 -0
- package/dist/transport/announce/types.d.ts +74 -0
- package/dist/transport/announce/types.js +16 -0
- package/dist/transport/bundle-store.d.ts +11 -0
- package/dist/transport/bundle-store.js +34 -0
- package/dist/transport/chase.d.ts +125 -0
- package/dist/transport/chase.js +182 -0
- package/dist/transport/gossip-validator.d.ts +107 -0
- package/dist/transport/gossip-validator.js +99 -0
- package/dist/transport/helia.d.ts +55 -0
- package/dist/transport/helia.js +114 -0
- package/dist/transport/integration/harness.d.ts +65 -0
- package/dist/transport/integration/harness.js +278 -0
- package/dist/transport/messages.d.ts +97 -0
- package/dist/transport/messages.js +117 -0
- package/dist/transport/rate-limit.d.ts +11 -0
- package/dist/transport/rate-limit.js +20 -0
- package/dist/transport/transport.d.ts +20 -0
- package/dist/transport/transport.js +35 -0
- package/dist/transport/types.d.ts +189 -0
- package/dist/transport/types.js +1 -0
- package/dist/verify/background.d.ts +81 -0
- package/dist/verify/background.js +236 -0
- package/dist/verify/bundle.d.ts +58 -0
- package/dist/verify/bundle.js +84 -0
- package/dist/verify/cache.d.ts +48 -0
- package/dist/verify/cache.js +62 -0
- package/dist/verify/constraints.d.ts +16 -0
- package/dist/verify/constraints.js +35 -0
- package/dist/verify/gate-result-cache.d.ts +65 -0
- package/dist/verify/gate-result-cache.js +91 -0
- package/dist/verify/name-resolution-cache.d.ts +59 -0
- package/dist/verify/name-resolution-cache.js +64 -0
- package/dist/verify/signature.d.ts +9 -0
- package/dist/verify/signature.js +55 -0
- package/dist/verify/types.d.ts +101 -0
- package/dist/verify/types.js +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CID } from "multiformats/cid";
|
|
2
|
+
import type { PeerId } from "@libp2p/interface";
|
|
3
|
+
import type { BlockstoreLike, FetchServiceLike, HeliaInstance, PubsubService } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The host's raw blockstore surface. `get` may return EITHER a `Promise<Uint8Array>` (a plain
|
|
6
|
+
* `interface-blockstore`) OR an async generator yielding the block's bytes — Helia's real
|
|
7
|
+
* `BlockStorage` implements the streaming `Blocks` interface and yields the block (one chunk in
|
|
8
|
+
* practice), not a bare promise. The library works against the simpler {@link BlockstoreLike}
|
|
9
|
+
* contract, so {@link adaptBlockstore} normalises either shape at this one boundary.
|
|
10
|
+
*/
|
|
11
|
+
interface RawBlockstore {
|
|
12
|
+
get(cid: CID, options?: {
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}): AsyncIterable<Uint8Array> | Promise<Uint8Array>;
|
|
15
|
+
put(cid: CID, block: Uint8Array, options?: {
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}): Promise<CID>;
|
|
18
|
+
has(cid: CID, options?: {
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}): Promise<boolean>;
|
|
21
|
+
/** Helia's `Blocks.createSession` (a provider-scoped session blockstore); plain blockstores lack it. */
|
|
22
|
+
createSession?(root: CID, options?: {
|
|
23
|
+
providers?: PeerId[];
|
|
24
|
+
maxProviders?: number;
|
|
25
|
+
}): RawBlockSession;
|
|
26
|
+
}
|
|
27
|
+
/** The raw session surface (Helia's `SessionBlockstore`); `get` streams like the parent store's. */
|
|
28
|
+
interface RawBlockSession {
|
|
29
|
+
get(cid: CID, options?: {
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
}): AsyncIterable<Uint8Array> | Promise<Uint8Array>;
|
|
32
|
+
addPeer(peer: PeerId): Promise<void> | void;
|
|
33
|
+
close(): void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Adapt a raw blockstore to the library's {@link BlockstoreLike} contract: normalise `get` to a
|
|
37
|
+
* single `Uint8Array`. Helia's `BlockStorage.get` yields the block over an async generator (a
|
|
38
|
+
* single chunk for the sub-1 MiB blocks this library stores; chunks are concatenated defensively),
|
|
39
|
+
* while a plain blockstore returns a promise — both are handled. `put`/`has` already return
|
|
40
|
+
* promises, so they pass through. Exported so the transport (and the integration harness) adapt the
|
|
41
|
+
* injected node the same way.
|
|
42
|
+
*/
|
|
43
|
+
export declare function adaptBlockstore(raw: RawBlockstore): BlockstoreLike;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve and validate the gossipsub service, blockstore, and fetch service on an injected
|
|
46
|
+
* Helia node, throwing {@link MissingPubsubError} / {@link MissingBlockstoreError} /
|
|
47
|
+
* {@link MissingFetchError} if any is absent or malformed. Returns the narrowed handles so
|
|
48
|
+
* callers do not re-check.
|
|
49
|
+
*/
|
|
50
|
+
export declare function requireHeliaServices(helia: HeliaInstance): {
|
|
51
|
+
pubsub: PubsubService;
|
|
52
|
+
blockstore: BlockstoreLike;
|
|
53
|
+
fetch: FetchServiceLike;
|
|
54
|
+
};
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { MissingBlockstoreError, MissingFetchError, MissingPubsubError } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* The helia/libp2p-touching glue. Like the rest of `transport/`, this is the only place
|
|
4
|
+
* that reaches into the host node; the core never imports it.
|
|
5
|
+
*
|
|
6
|
+
* `requireHeliaServices` is the one piece live today: the host injects its running Helia
|
|
7
|
+
* node directly (no adapter), and we cannot trust the type — `libp2p.services.pubsub` is
|
|
8
|
+
* `unknown`, a plain Helia node has none, and a malformed object may lack a blockstore —
|
|
9
|
+
* so we validate both at construction and fail fast (`MissingPubsubError` /
|
|
10
|
+
* `MissingBlockstoreError`) instead of letting a later publish/subscribe/fetch throw
|
|
11
|
+
* obscurely.
|
|
12
|
+
*
|
|
13
|
+
* Note "bitswap" is not separately checkable: it is a block broker wired *beneath*
|
|
14
|
+
* `blockstore`, not a property of the Helia node, so validating the blockstore (the
|
|
15
|
+
* surface bitswap retrieves through) is the closest honest guarantee.
|
|
16
|
+
*/
|
|
17
|
+
/** Does `value` look like a pubsub service we can drive (the methods we depend on)? */
|
|
18
|
+
function isPubsubService(value) {
|
|
19
|
+
if (value === null || typeof value !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
const candidate = value;
|
|
22
|
+
return (typeof candidate.publish === "function" &&
|
|
23
|
+
typeof candidate.subscribe === "function" &&
|
|
24
|
+
typeof candidate.unsubscribe === "function");
|
|
25
|
+
}
|
|
26
|
+
/** Does `value` look like a blockstore we can fetch/store blocks through? */
|
|
27
|
+
function isRawBlockstore(value) {
|
|
28
|
+
if (value === null || typeof value !== "object")
|
|
29
|
+
return false;
|
|
30
|
+
const candidate = value;
|
|
31
|
+
return (typeof candidate.get === "function" &&
|
|
32
|
+
typeof candidate.put === "function" &&
|
|
33
|
+
typeof candidate.has === "function");
|
|
34
|
+
}
|
|
35
|
+
function isAsyncIterable(value) {
|
|
36
|
+
return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Adapt a raw blockstore to the library's {@link BlockstoreLike} contract: normalise `get` to a
|
|
40
|
+
* single `Uint8Array`. Helia's `BlockStorage.get` yields the block over an async generator (a
|
|
41
|
+
* single chunk for the sub-1 MiB blocks this library stores; chunks are concatenated defensively),
|
|
42
|
+
* while a plain blockstore returns a promise — both are handled. `put`/`has` already return
|
|
43
|
+
* promises, so they pass through. Exported so the transport (and the integration harness) adapt the
|
|
44
|
+
* injected node the same way.
|
|
45
|
+
*/
|
|
46
|
+
export function adaptBlockstore(raw) {
|
|
47
|
+
const adapted = {
|
|
48
|
+
get: (cid, options) => readBlock(raw.get(cid, options), cid),
|
|
49
|
+
put: (cid, block) => raw.put(cid, block),
|
|
50
|
+
has: (cid) => raw.has(cid)
|
|
51
|
+
};
|
|
52
|
+
// Feature-detected, not assumed: only Helia's `Blocks` makes sessions; the unit tests' plain
|
|
53
|
+
// blockstores don't, and their absence here is what tells the chase to broadcast instead.
|
|
54
|
+
// Dispatched dynamically (like `get`/`put`/`has` above) so a wrapper installed on the raw
|
|
55
|
+
// store after adaptation — e.g. the benchmark's timing instrumentation — is still honoured.
|
|
56
|
+
if (typeof raw.createSession === "function") {
|
|
57
|
+
adapted.createSession = (root, options) => {
|
|
58
|
+
const session = raw.createSession(root, options);
|
|
59
|
+
return {
|
|
60
|
+
get: (cid, opts) => readBlock(session.get(cid, opts), cid),
|
|
61
|
+
addPeer: (peer) => session.addPeer(peer),
|
|
62
|
+
close: () => session.close()
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return adapted;
|
|
67
|
+
}
|
|
68
|
+
/** Normalise one raw `get` result (promise or stream — see {@link RawBlockstore}) to the block's bytes. */
|
|
69
|
+
async function readBlock(result, cid) {
|
|
70
|
+
if (!isAsyncIterable(result))
|
|
71
|
+
return result; // a plain Promise<Uint8Array> blockstore
|
|
72
|
+
const chunks = [];
|
|
73
|
+
for await (const chunk of result)
|
|
74
|
+
chunks.push(chunk);
|
|
75
|
+
if (chunks.length === 1)
|
|
76
|
+
return chunks[0];
|
|
77
|
+
if (chunks.length === 0)
|
|
78
|
+
throw new Error(`block ${cid.toString()} yielded no bytes`);
|
|
79
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
80
|
+
const out = new Uint8Array(total);
|
|
81
|
+
let offset = 0;
|
|
82
|
+
for (const chunk of chunks) {
|
|
83
|
+
out.set(chunk, offset);
|
|
84
|
+
offset += chunk.length;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Does `value` look like a libp2p fetch service (the request + responder registration)? */
|
|
89
|
+
function isFetchService(value) {
|
|
90
|
+
if (value === null || typeof value !== "object")
|
|
91
|
+
return false;
|
|
92
|
+
const candidate = value;
|
|
93
|
+
return (typeof candidate.fetch === "function" &&
|
|
94
|
+
typeof candidate.registerLookupFunction === "function" &&
|
|
95
|
+
typeof candidate.unregisterLookupFunction === "function");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve and validate the gossipsub service, blockstore, and fetch service on an injected
|
|
99
|
+
* Helia node, throwing {@link MissingPubsubError} / {@link MissingBlockstoreError} /
|
|
100
|
+
* {@link MissingFetchError} if any is absent or malformed. Returns the narrowed handles so
|
|
101
|
+
* callers do not re-check.
|
|
102
|
+
*/
|
|
103
|
+
export function requireHeliaServices(helia) {
|
|
104
|
+
const pubsub = helia.libp2p?.services?.pubsub;
|
|
105
|
+
if (!isPubsubService(pubsub))
|
|
106
|
+
throw new MissingPubsubError();
|
|
107
|
+
const blockstore = helia.blockstore;
|
|
108
|
+
if (!isRawBlockstore(blockstore))
|
|
109
|
+
throw new MissingBlockstoreError();
|
|
110
|
+
const fetch = helia.libp2p?.services?.fetch;
|
|
111
|
+
if (!isFetchService(fetch))
|
|
112
|
+
throw new MissingFetchError();
|
|
113
|
+
return { pubsub, blockstore: adaptBlockstore(blockstore), fetch };
|
|
114
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Libp2p } from "libp2p";
|
|
2
|
+
import { type Helia } from "helia";
|
|
3
|
+
import type { VotesBundle } from "../../schema/votes.js";
|
|
4
|
+
import type { BundleVerdict } from "../../verify/types.js";
|
|
5
|
+
import type { VoteCrdt } from "../../crdt/types.js";
|
|
6
|
+
import { type VerdictCache } from "../../verify/cache.js";
|
|
7
|
+
import { type RootChaser } from "../chase.js";
|
|
8
|
+
import type { PubsubService, VoteTransport } from "../types.js";
|
|
9
|
+
import { type RootRecord } from "../messages.js";
|
|
10
|
+
/** The gossipsub peer-score methods used for assertions — on the concrete class, not the interface. */
|
|
11
|
+
interface ScoreOps {
|
|
12
|
+
getScore(peer: string): number;
|
|
13
|
+
getMeshPeers(topic: string): string[];
|
|
14
|
+
}
|
|
15
|
+
export interface VoteNodeOptions {
|
|
16
|
+
/** Per-message validation deadline (ms). Small values let a test force the deadline path. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface VoteNode {
|
|
20
|
+
readonly helia: Helia;
|
|
21
|
+
readonly libp2p: Libp2p;
|
|
22
|
+
/** The gossipsub service, plus the score/mesh introspection the assertions read. */
|
|
23
|
+
readonly pubsub: PubsubService & ScoreOps;
|
|
24
|
+
readonly peerId: string;
|
|
25
|
+
readonly topic: string;
|
|
26
|
+
readonly crdt: VoteCrdt;
|
|
27
|
+
readonly cache: VerdictCache;
|
|
28
|
+
readonly chaser: RootChaser;
|
|
29
|
+
readonly transport: VoteTransport;
|
|
30
|
+
/** Bundle-kind messages this node ACCEPTED (delivered post-validation ⇒ it would forward them). */
|
|
31
|
+
readonly acceptedBundles: Uint8Array[];
|
|
32
|
+
/** Root records this node heard through the gate. */
|
|
33
|
+
readonly heardRoots: RootRecord[];
|
|
34
|
+
/** Chase sessions this node opened: which root, seeded with which advertisers (peer id strings). */
|
|
35
|
+
readonly openedSessions: Array<{
|
|
36
|
+
root: string;
|
|
37
|
+
providers: string[];
|
|
38
|
+
}>;
|
|
39
|
+
/** True once a heard root matched this node's own current root (heartbeat suppression signal). */
|
|
40
|
+
heardMatchingRoot(): boolean;
|
|
41
|
+
/** Replace the injected verifier (e.g. a rejecter, or a slow one, for a specific assertion). */
|
|
42
|
+
setVerifier(verify: (bundle: VotesBundle) => Promise<BundleVerdict>): void;
|
|
43
|
+
/** Encode this node's current winner-set to a checkpoint (blocks written to its blockstore). */
|
|
44
|
+
checkpointRootRecord(): Promise<RootRecord>;
|
|
45
|
+
/** Publish this node's own root record on the topic (a heartbeat). */
|
|
46
|
+
publishOwnRoot(): Promise<void>;
|
|
47
|
+
/** Seed a bundle straight into this node's state (store block + CRDT merge), no network. */
|
|
48
|
+
admitBundle(bundle: VotesBundle): Promise<void>;
|
|
49
|
+
stop(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/** Build one real node with the full forward-gate wired to real gossipsub. */
|
|
52
|
+
export declare function makeVoteNode(topic: string, options?: VoteNodeOptions): Promise<VoteNode>;
|
|
53
|
+
/**
|
|
54
|
+
* Dial `b` from `a` and wait until gossipsub has grafted them into each other's mesh for
|
|
55
|
+
* `topic` — after which a publish is reliably delivered (so negative assertions like "not
|
|
56
|
+
* forwarded" are meaningful, not just races against an unformed mesh).
|
|
57
|
+
*/
|
|
58
|
+
export declare function connectNodes(a: VoteNode, b: VoteNode): Promise<void>;
|
|
59
|
+
/** Poll `predicate` until it is truthy or `timeoutMs` elapses (then throw with `description`). */
|
|
60
|
+
export declare function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs?: number, description?: string): Promise<void>;
|
|
61
|
+
/** Resolve after `ms`; used only to space out polls, never as an observation substitute. */
|
|
62
|
+
export declare function delay(ms: number): Promise<void>;
|
|
63
|
+
/** A well-formed one-vote bundle. The signature is a well-formed 65 bytes; validity is the verifier's job. */
|
|
64
|
+
export declare function sampleBundle(address: string, publicKey: string, blockNumber?: number): VotesBundle;
|
|
65
|
+
export {};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import pLimit from "p-limit";
|
|
2
|
+
import { createLibp2p } from "libp2p";
|
|
3
|
+
import { tcp } from "@libp2p/tcp";
|
|
4
|
+
import { noise } from "@chainsafe/libp2p-noise";
|
|
5
|
+
import { yamux } from "@chainsafe/libp2p-yamux";
|
|
6
|
+
import { identify } from "@libp2p/identify";
|
|
7
|
+
import { gossipsub } from "@libp2p/gossipsub";
|
|
8
|
+
import { fetch as fetchService } from "@libp2p/fetch";
|
|
9
|
+
import { createHelia } from "helia";
|
|
10
|
+
import { makeBlockstoreBundleStore } from "../bundle-store.js";
|
|
11
|
+
import { adaptBlockstore } from "../helia.js";
|
|
12
|
+
import { makeVoteCrdt } from "../../crdt/crdt.js";
|
|
13
|
+
import { makeBucketMath } from "../../chain/bucket.js";
|
|
14
|
+
import { makeVerdictCache } from "../../verify/cache.js";
|
|
15
|
+
import { encodeBundle, decodeBundle, bundleCidForBytes } from "../../crdt/codec.js";
|
|
16
|
+
import { encodeCheckpoint } from "../../checkpoint/codec.js";
|
|
17
|
+
import { makeGossipGate } from "../gossip-validator.js";
|
|
18
|
+
import { makeRootChaser, toChaseSession } from "../chase.js";
|
|
19
|
+
import { makeVoteTransport } from "../transport.js";
|
|
20
|
+
import { decodeVoteMessage, maxBundleMessageBytes, MAX_ROOT_MESSAGE_BYTES, ROOT_RECORD_VERSION } from "../messages.js";
|
|
21
|
+
/**
|
|
22
|
+
* Test harness for the two-node gossipsub integration test. It stands up ONE real libp2p +
|
|
23
|
+
* Helia node carrying `@libp2p/gossipsub` (>= 15.0.23, the CVE-2026-46679 floor) and
|
|
24
|
+
* `@libp2p/fetch`, and wires the SAME forward-gate / chaser / transport the production client
|
|
25
|
+
* assembles in `src/client/voter.ts` `start()` — the only differences are test seams: an
|
|
26
|
+
* injectable verifier (so a test can force a reject or a slow verify) and a small `timeoutMs`.
|
|
27
|
+
* No production `src/` code is modified; this reuses `makeGossipGate` / `makeRootChaser` /
|
|
28
|
+
* `makeVoteTransport` verbatim.
|
|
29
|
+
*
|
|
30
|
+
* What only a real node can prove (and a fake pubsub cannot): gossipsub actually forwards an
|
|
31
|
+
* accepted message and drops a rejected one, peer scores move on a `reject`, a validation past
|
|
32
|
+
* the deadline yields `ignore` with no penalty, a converged pair stays quiet, and a divergent
|
|
33
|
+
* root pulls the checkpoint blocks over real bitswap.
|
|
34
|
+
*/
|
|
35
|
+
/** Every bundle in the test is dated to a small block; reads use bucket 0 (never expired). */
|
|
36
|
+
const CURRENT_BUCKET = 0;
|
|
37
|
+
const BLOCKS_PER_BUCKET = 43_200;
|
|
38
|
+
const VOTE_EXPIRY_BUCKETS = 30;
|
|
39
|
+
/** A permissive default verifier; individual tests swap it via {@link VoteNode.setVerifier}. */
|
|
40
|
+
const okVerifier = () => ({ valid: true, ruleScore: 1n, resolvedNames: {} });
|
|
41
|
+
/** Build one real node with the full forward-gate wired to real gossipsub. */
|
|
42
|
+
export async function makeVoteNode(topic, options = {}) {
|
|
43
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
44
|
+
const libp2p = await createLibp2p({
|
|
45
|
+
addresses: { listen: ["/ip4/127.0.0.1/tcp/0"] },
|
|
46
|
+
transports: [tcp()],
|
|
47
|
+
connectionEncrypters: [noise()],
|
|
48
|
+
streamMuxers: [yamux()],
|
|
49
|
+
services: {
|
|
50
|
+
identify: identify(),
|
|
51
|
+
fetch: fetchService(),
|
|
52
|
+
pubsub: gossipsub({
|
|
53
|
+
allowPublishToZeroTopicPeers: true,
|
|
54
|
+
heartbeatInterval: 300,
|
|
55
|
+
// Isolate the invalid-message penalty (P₄) as the only score signal so a reject is
|
|
56
|
+
// crisply observable and an `ignore` provably moves nothing. Positive/mesh weights
|
|
57
|
+
// are zeroed; IP-colocation is disabled because both nodes share 127.0.0.1.
|
|
58
|
+
scoreParams: {
|
|
59
|
+
IPColocationFactorWeight: 0,
|
|
60
|
+
appSpecificScore: () => 0,
|
|
61
|
+
topics: {
|
|
62
|
+
[topic]: {
|
|
63
|
+
topicWeight: 1,
|
|
64
|
+
timeInMeshWeight: 0,
|
|
65
|
+
timeInMeshQuantum: 1_000,
|
|
66
|
+
timeInMeshCap: 1,
|
|
67
|
+
firstMessageDeliveriesWeight: 0,
|
|
68
|
+
firstMessageDeliveriesDecay: 0.5,
|
|
69
|
+
firstMessageDeliveriesCap: 100,
|
|
70
|
+
meshMessageDeliveriesWeight: 0,
|
|
71
|
+
meshMessageDeliveriesDecay: 0.5,
|
|
72
|
+
meshMessageDeliveriesCap: 100,
|
|
73
|
+
meshMessageDeliveriesThreshold: 1,
|
|
74
|
+
meshMessageDeliveriesWindow: 10,
|
|
75
|
+
meshMessageDeliveriesActivation: 5_000,
|
|
76
|
+
meshFailurePenaltyWeight: 0,
|
|
77
|
+
meshFailurePenaltyDecay: 0.5,
|
|
78
|
+
// One invalid delivery ⇒ score −50: clearly negative, below the publish
|
|
79
|
+
// threshold, but above the −80 graylist so the peer is not disconnected
|
|
80
|
+
// mid-assertion.
|
|
81
|
+
invalidMessageDeliveriesWeight: -50,
|
|
82
|
+
invalidMessageDeliveriesDecay: 0.9
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
const helia = await createHelia({ libp2p });
|
|
90
|
+
const pubsub = libp2p.services.pubsub;
|
|
91
|
+
// Adapt Helia's async-generator `get` to the library's Promise-returning BlockstoreLike, the
|
|
92
|
+
// same bridge `requireHeliaServices` applies to an injected host node.
|
|
93
|
+
const blockstore = adaptBlockstore(helia.blockstore);
|
|
94
|
+
const store = makeBlockstoreBundleStore(blockstore);
|
|
95
|
+
const crdt = makeVoteCrdt({
|
|
96
|
+
store,
|
|
97
|
+
bucketMath: makeBucketMath(BLOCKS_PER_BUCKET),
|
|
98
|
+
voteExpiryBuckets: VOTE_EXPIRY_BUCKETS
|
|
99
|
+
});
|
|
100
|
+
const cache = makeVerdictCache();
|
|
101
|
+
// The injected verifier, swappable per test. The gate and the chaser share this reference.
|
|
102
|
+
// The chaser's offline stage runs the same swapped implementation, so a test that makes the
|
|
103
|
+
// verifier fail still sees the chase drop the bundle BEFORE admit (the two-node assertions
|
|
104
|
+
// pin admission, not which stage deferred — deferred checks are unit-tested in
|
|
105
|
+
// verify/background.test.ts).
|
|
106
|
+
let verifyImpl = async () => okVerifier();
|
|
107
|
+
const verifier = {
|
|
108
|
+
verify: (bundle) => verifyImpl(bundle),
|
|
109
|
+
verifyOffline: (bundle) => verifyImpl(bundle)
|
|
110
|
+
};
|
|
111
|
+
const admit = async ({ cid, bytes }) => {
|
|
112
|
+
await blockstore.put(cid, bytes);
|
|
113
|
+
await crdt.merge([cid]);
|
|
114
|
+
};
|
|
115
|
+
const acceptedBundles = [];
|
|
116
|
+
const heardRoots = [];
|
|
117
|
+
let matchedOwnRoot = false;
|
|
118
|
+
async function checkpointRootRecord() {
|
|
119
|
+
const winners = crdt.current(CURRENT_BUCKET);
|
|
120
|
+
const { root, blocks } = await encodeCheckpoint(winners);
|
|
121
|
+
for (const block of blocks)
|
|
122
|
+
await blockstore.put(block.cid, block.bytes);
|
|
123
|
+
return {
|
|
124
|
+
version: ROOT_RECORD_VERSION,
|
|
125
|
+
root,
|
|
126
|
+
count: winners.length,
|
|
127
|
+
sizeBytes: blocks.reduce((total, block) => total + block.bytes.length, 0)
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const openedSessions = [];
|
|
131
|
+
const chaseLimit = pLimit(2);
|
|
132
|
+
const chaser = makeRootChaser({
|
|
133
|
+
getBlock: async (cid, signal) => {
|
|
134
|
+
try {
|
|
135
|
+
return await blockstore.get(cid, { signal });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
// Mirror `PubsubVoter`'s seeded-session wiring (voter.ts `openSession`), with a recorder
|
|
142
|
+
// so the test can assert the chase really pulled through a session seeded with the
|
|
143
|
+
// advertiser — convergence alone cannot tell the session path from the broadcast fallback.
|
|
144
|
+
openSession: (root, providers) => {
|
|
145
|
+
const createSession = blockstore.createSession?.bind(blockstore);
|
|
146
|
+
if (createSession === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
openedSessions.push({ root: root.toString(), providers: providers.map((p) => p.toString()) });
|
|
149
|
+
return toChaseSession(createSession(root, { providers, maxProviders: providers.length + 1 }));
|
|
150
|
+
},
|
|
151
|
+
verifyOffline: (bundle) => verifier.verifyOffline(bundle),
|
|
152
|
+
cache,
|
|
153
|
+
hasBundle: (cid) => store.has(cid),
|
|
154
|
+
admit,
|
|
155
|
+
// The harness runs the whole swapped pipeline in `verifyOffline` above, so nothing is
|
|
156
|
+
// left deferred; the background verifier has its own unit tests.
|
|
157
|
+
deferVerify: () => { },
|
|
158
|
+
limit: (fn) => chaseLimit(fn),
|
|
159
|
+
timeoutMs: 30_000
|
|
160
|
+
});
|
|
161
|
+
const gateLimit = pLimit(8);
|
|
162
|
+
const gate = makeGossipGate({
|
|
163
|
+
decodeMessage: decodeVoteMessage,
|
|
164
|
+
parseBundle: async (blockBytes) => ({
|
|
165
|
+
cid: await bundleCidForBytes(blockBytes),
|
|
166
|
+
bundle: decodeBundle(blockBytes)
|
|
167
|
+
}),
|
|
168
|
+
verifier,
|
|
169
|
+
cache,
|
|
170
|
+
admit,
|
|
171
|
+
limit: (fn) => gateLimit(fn),
|
|
172
|
+
allowBundlePeer: () => true,
|
|
173
|
+
allowRootPeer: () => true,
|
|
174
|
+
onAccept: (_cid, _bundle, _from) => { },
|
|
175
|
+
// Mirror `PubsubVoter.#handleRootRecord`: a matching root is the suppression signal (no
|
|
176
|
+
// chase, no echo); a divergent root is chased over directed bitswap, its session seeded
|
|
177
|
+
// with the sender — resolved from the live connections exactly as the voter does.
|
|
178
|
+
onRootRecord: (record, from) => {
|
|
179
|
+
heardRoots.push(record);
|
|
180
|
+
void (async () => {
|
|
181
|
+
const own = await checkpointRootRecord();
|
|
182
|
+
if (own.root.equals(record.root)) {
|
|
183
|
+
matchedOwnRoot = true;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const advertiser = libp2p
|
|
187
|
+
.getConnections()
|
|
188
|
+
.find((connection) => connection.remotePeer.toString() === from)?.remotePeer;
|
|
189
|
+
chaser.chase(record.root, undefined, advertiser === undefined ? [] : [advertiser]);
|
|
190
|
+
})().catch(() => { });
|
|
191
|
+
},
|
|
192
|
+
maxBundleMessageBytes: maxBundleMessageBytes({ maxVotesPerAddress: 1 }),
|
|
193
|
+
maxRootMessageBytes: MAX_ROOT_MESSAGE_BYTES,
|
|
194
|
+
timeoutMs
|
|
195
|
+
});
|
|
196
|
+
const transport = makeVoteTransport({ pubsub, topic, gate });
|
|
197
|
+
await transport.start();
|
|
198
|
+
// Record every ACCEPTED message. gossipsub emits "message" only after the topic validator
|
|
199
|
+
// returns accept, so a delivered bundle is one this node forwarded — and a rejected/ignored
|
|
200
|
+
// one never appears here. This is the observable for the forward / no-forward assertions.
|
|
201
|
+
pubsub.addEventListener("message", (evt) => {
|
|
202
|
+
if (evt.detail.topic !== topic)
|
|
203
|
+
return;
|
|
204
|
+
try {
|
|
205
|
+
const message = decodeVoteMessage(evt.detail.data);
|
|
206
|
+
if (message.kind === "bundle")
|
|
207
|
+
acceptedBundles.push(message.bundle);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// A delivered message always decodes (it passed the gate); ignore anything else.
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
return {
|
|
214
|
+
helia,
|
|
215
|
+
libp2p,
|
|
216
|
+
pubsub,
|
|
217
|
+
peerId: libp2p.peerId.toString(),
|
|
218
|
+
topic,
|
|
219
|
+
crdt,
|
|
220
|
+
cache,
|
|
221
|
+
chaser,
|
|
222
|
+
transport,
|
|
223
|
+
acceptedBundles,
|
|
224
|
+
heardRoots,
|
|
225
|
+
openedSessions,
|
|
226
|
+
heardMatchingRoot: () => matchedOwnRoot,
|
|
227
|
+
setVerifier: (verify) => {
|
|
228
|
+
verifyImpl = verify;
|
|
229
|
+
},
|
|
230
|
+
checkpointRootRecord,
|
|
231
|
+
publishOwnRoot: async () => {
|
|
232
|
+
await transport.publishRootRecord(await checkpointRootRecord());
|
|
233
|
+
},
|
|
234
|
+
admitBundle: async (bundle) => {
|
|
235
|
+
const bytes = encodeBundle(bundle);
|
|
236
|
+
const cid = await bundleCidForBytes(bytes);
|
|
237
|
+
await blockstore.put(cid, bytes);
|
|
238
|
+
await crdt.merge([cid]);
|
|
239
|
+
},
|
|
240
|
+
stop: async () => {
|
|
241
|
+
await transport.stop();
|
|
242
|
+
await helia.stop();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Dial `b` from `a` and wait until gossipsub has grafted them into each other's mesh for
|
|
248
|
+
* `topic` — after which a publish is reliably delivered (so negative assertions like "not
|
|
249
|
+
* forwarded" are meaningful, not just races against an unformed mesh).
|
|
250
|
+
*/
|
|
251
|
+
export async function connectNodes(a, b) {
|
|
252
|
+
await a.libp2p.dial(b.libp2p.getMultiaddrs());
|
|
253
|
+
await waitFor(() => a.pubsub.getMeshPeers(a.topic).includes(b.peerId) && b.pubsub.getMeshPeers(b.topic).includes(a.peerId), 15_000, "gossipsub mesh to form between the two nodes");
|
|
254
|
+
}
|
|
255
|
+
/** Poll `predicate` until it is truthy or `timeoutMs` elapses (then throw with `description`). */
|
|
256
|
+
export async function waitFor(predicate, timeoutMs = 15_000, description = "condition") {
|
|
257
|
+
const deadline = Date.now() + timeoutMs;
|
|
258
|
+
for (;;) {
|
|
259
|
+
if (await predicate())
|
|
260
|
+
return;
|
|
261
|
+
if (Date.now() > deadline)
|
|
262
|
+
throw new Error(`timed out after ${timeoutMs}ms waiting for ${description}`);
|
|
263
|
+
await delay(50);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Resolve after `ms`; used only to space out polls, never as an observation substitute. */
|
|
267
|
+
export function delay(ms) {
|
|
268
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
269
|
+
}
|
|
270
|
+
/** A well-formed one-vote bundle. The signature is a well-formed 65 bytes; validity is the verifier's job. */
|
|
271
|
+
export function sampleBundle(address, publicKey, blockNumber = 10) {
|
|
272
|
+
return {
|
|
273
|
+
address,
|
|
274
|
+
votes: [{ community: { publicKey }, vote: 1 }],
|
|
275
|
+
blockNumber,
|
|
276
|
+
signature: { signature: `0x${"11".repeat(65)}`, type: "eip712" }
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CID } from "multiformats/cid";
|
|
2
|
+
import type { Criteria } from "../schema/criteria.js";
|
|
3
|
+
/**
|
|
4
|
+
* The pubsub message payload: a two-kind discriminated union (see DESIGN.md "Transport").
|
|
5
|
+
*
|
|
6
|
+
* - `bundle`: one wallet's own bundle as a **live delta** — the exact binary bundle-block
|
|
7
|
+
* bytes (crdt/codec.ts) inlined, so the receiver validates straight from the message,
|
|
8
|
+
* hashing the embedded bytes yields the bundle CID (the verdict-cache key), and the
|
|
9
|
+
* blockstore put is byte-identical. No fetch toward the publisher exists on this path.
|
|
10
|
+
* - `root`: the constant-size **root record** `{ version, root, count, sizeBytes }` — the
|
|
11
|
+
* checkpoint heartbeat (see DESIGN.md "Checkpoints"). An unverifiable *hint*, never
|
|
12
|
+
* trusted; the same record also travels over the libp2p fetch protocol, so its codec is
|
|
13
|
+
* standalone (`encodeRootRecord`/`decodeRootRecord`).
|
|
14
|
+
*
|
|
15
|
+
* A message carries **no authority** either way: all trust comes from re-verifying the
|
|
16
|
+
* self-authenticating bundle (or the self-verifying blocks behind a root) before acting.
|
|
17
|
+
* Encoding is the same canonical dag-cbor as the rest of the protocol; the layout is pinned
|
|
18
|
+
* by fixed test vectors in `messages.test.ts` — any change is a breaking wire change.
|
|
19
|
+
*/
|
|
20
|
+
/** Envelope wire version (the root record carries its own `version` for the fetch path). */
|
|
21
|
+
export declare const MESSAGE_VERSION = 1;
|
|
22
|
+
export declare const ROOT_RECORD_VERSION = 1;
|
|
23
|
+
/** A topic's compacted-state advertisement: tiny, constant-size, unauthenticated (a hint). */
|
|
24
|
+
export interface RootRecord {
|
|
25
|
+
version: number;
|
|
26
|
+
/** The checkpoint root CID of the advertiser's current winner-set. */
|
|
27
|
+
root: CID;
|
|
28
|
+
/** Advisory: winners inlined behind the root. Unverifiable until the blocks arrive. */
|
|
29
|
+
count: number;
|
|
30
|
+
/** Advisory: total checkpoint block bytes behind the root. Unverifiable until they arrive. */
|
|
31
|
+
sizeBytes: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The **fetch-protocol** root response: the advertisement plus the checkpoint's chunk-CID index
|
|
35
|
+
* (the root manifest's contents). Carrying it lets a cold joiner skip the root-manifest bitswap
|
|
36
|
+
* round-trip — it re-derives `CID(dag-cbor({ chunks }))`, checks it equals `root` (self-verifying,
|
|
37
|
+
* so the index is never a new trust vector), and pulls every chunk in parallel (see DESIGN.md
|
|
38
|
+
* "Checkpoints", "Block pull"). Only the *direct fetch response* carries the index; the pubsub
|
|
39
|
+
* heartbeat stays the bare {@link RootRecord} so its message cap remains a tiny fixed constant
|
|
40
|
+
* (the anti-amplification property — the index is O(sizeBytes / chunk-ceiling), a ~36 B/MiB
|
|
41
|
+
* pointer list, not the payload, but the broadcast path keeps the stricter guarantee anyway).
|
|
42
|
+
*/
|
|
43
|
+
export interface FetchRootRecord extends RootRecord {
|
|
44
|
+
/** The checkpoint's chunk CIDs, in root order. `CID(dag-cbor({ chunks })) === root`. */
|
|
45
|
+
chunks: CID[];
|
|
46
|
+
}
|
|
47
|
+
export type VoteMessage = {
|
|
48
|
+
kind: "bundle";
|
|
49
|
+
bundle: Uint8Array;
|
|
50
|
+
} | {
|
|
51
|
+
kind: "root";
|
|
52
|
+
record: RootRecord;
|
|
53
|
+
};
|
|
54
|
+
/** Encode one bundle's binary block bytes as a live-delta message. */
|
|
55
|
+
export declare function encodeBundleMessage(blockBytes: Uint8Array): Uint8Array;
|
|
56
|
+
/**
|
|
57
|
+
* Encode a root record as a heartbeat message. The broadcast heartbeat carries only the bare
|
|
58
|
+
* advertisement (never the fetch response's chunk index), so its size stays a tiny fixed
|
|
59
|
+
* constant — accepts a {@link FetchRootRecord} and drops the index (see {@link FetchRootRecord}).
|
|
60
|
+
*/
|
|
61
|
+
export declare function encodeRootMessage(record: RootRecord): Uint8Array;
|
|
62
|
+
/**
|
|
63
|
+
* Decode a pubsub payload to its message kind, throwing on anything malformed. The gate
|
|
64
|
+
* treats a throw as layer-1 badness (`reject`) — the cheapest, pre-verify check.
|
|
65
|
+
*/
|
|
66
|
+
export declare function decodeVoteMessage(data: Uint8Array): VoteMessage;
|
|
67
|
+
/**
|
|
68
|
+
* The libp2p fetch-protocol key suffix for a topic's root record: the full key is
|
|
69
|
+
* `topic + "/root"` (the topic prefix only namespaces *which* contest a multi-contest
|
|
70
|
+
* responder is asked about — it is not a pubsub topic). See DESIGN.md "Checkpoints".
|
|
71
|
+
*/
|
|
72
|
+
export declare const ROOT_FETCH_KEY_SUFFIX = "/root";
|
|
73
|
+
/** The fetch-protocol key for one contest's root record. */
|
|
74
|
+
export declare function rootFetchKey(topic: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* Standalone root-record codec — the record served over the libp2p fetch protocol, carrying the
|
|
77
|
+
* chunk-CID index so a cold joiner can skip the root-manifest round-trip (see {@link FetchRootRecord}).
|
|
78
|
+
*/
|
|
79
|
+
export declare function encodeRootRecord(record: FetchRootRecord): Uint8Array;
|
|
80
|
+
/** Decode a fetch-protocol root-record value (with its chunk index); throws on malformed. */
|
|
81
|
+
export declare function decodeRootRecord(bytes: Uint8Array): FetchRootRecord;
|
|
82
|
+
/**
|
|
83
|
+
* The fixed byte cap for a root-kind message. The record is ~100 B by construction (version +
|
|
84
|
+
* CID link + two small ints + envelope), so this is a generous constant — anything larger is
|
|
85
|
+
* provably not a well-formed root message.
|
|
86
|
+
*/
|
|
87
|
+
export declare const MAX_ROOT_MESSAGE_BYTES = 256;
|
|
88
|
+
/**
|
|
89
|
+
* The derived per-message cap for a bundle-kind message — a pure function of the criteria
|
|
90
|
+
* (see DESIGN.md "Message size cap"): the criteria's own `maxVotesPerAddress` bounds the
|
|
91
|
+
* entry count, the schema's fixed field bounds (253-byte name, binary crypto fields) bound
|
|
92
|
+
* the entry size, so every peer computes the same cap from the same criteria bytes and an
|
|
93
|
+
* over-cap `reject` stays deterministic, penalizable, and cacheable. Deliberately NOT a
|
|
94
|
+
* criteria field: a raw byte knob could contradict `maxVotesPerAddress` and reject valid
|
|
95
|
+
* bundles.
|
|
96
|
+
*/
|
|
97
|
+
export declare function maxBundleMessageBytes(criteria: Pick<Criteria, "maxVotesPerAddress">): number;
|