@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.
Files changed (119) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +223 -0
  3. package/dist/chain/bucket.d.ts +13 -0
  4. package/dist/chain/bucket.js +24 -0
  5. package/dist/chain/coalescer.d.ts +56 -0
  6. package/dist/chain/coalescer.js +217 -0
  7. package/dist/chain/ticker.d.ts +15 -0
  8. package/dist/chain/ticker.js +25 -0
  9. package/dist/chain/types.d.ts +90 -0
  10. package/dist/chain/types.js +1 -0
  11. package/dist/checkpoint/codec.d.ts +54 -0
  12. package/dist/checkpoint/codec.js +99 -0
  13. package/dist/client/root-puller.d.ts +49 -0
  14. package/dist/client/root-puller.js +140 -0
  15. package/dist/client/voter.d.ts +239 -0
  16. package/dist/client/voter.js +1343 -0
  17. package/dist/crdt/codec.d.ts +41 -0
  18. package/dist/crdt/codec.js +137 -0
  19. package/dist/crdt/crdt.d.ts +22 -0
  20. package/dist/crdt/crdt.js +127 -0
  21. package/dist/crdt/store.d.ts +8 -0
  22. package/dist/crdt/store.js +23 -0
  23. package/dist/crdt/types.d.ts +87 -0
  24. package/dist/crdt/types.js +1 -0
  25. package/dist/encoding/canonical.d.ts +22 -0
  26. package/dist/encoding/canonical.js +26 -0
  27. package/dist/errors.d.ts +81 -0
  28. package/dist/errors.js +126 -0
  29. package/dist/index.d.ts +32 -0
  30. package/dist/index.js +43 -0
  31. package/dist/rules/constant.d.ts +14 -0
  32. package/dist/rules/constant.js +18 -0
  33. package/dist/rules/erc20-balance.d.ts +30 -0
  34. package/dist/rules/erc20-balance.js +44 -0
  35. package/dist/rules/erc721-min-balance.d.ts +18 -0
  36. package/dist/rules/erc721-min-balance.js +100 -0
  37. package/dist/rules/registry.d.ts +42 -0
  38. package/dist/rules/registry.js +61 -0
  39. package/dist/rules/types.d.ts +74 -0
  40. package/dist/rules/types.js +1 -0
  41. package/dist/schema/common.d.ts +25 -0
  42. package/dist/schema/common.js +24 -0
  43. package/dist/schema/criteria.d.ts +78 -0
  44. package/dist/schema/criteria.js +76 -0
  45. package/dist/schema/directory.d.ts +42 -0
  46. package/dist/schema/directory.js +52 -0
  47. package/dist/schema/votes.d.ts +55 -0
  48. package/dist/schema/votes.js +116 -0
  49. package/dist/signer/eip712.d.ts +103 -0
  50. package/dist/signer/eip712.js +85 -0
  51. package/dist/signer/types.d.ts +31 -0
  52. package/dist/signer/types.js +1 -0
  53. package/dist/storage/browser.d.ts +3 -0
  54. package/dist/storage/browser.js +110 -0
  55. package/dist/storage/memory.d.ts +10 -0
  56. package/dist/storage/memory.js +56 -0
  57. package/dist/storage/node.d.ts +5 -0
  58. package/dist/storage/node.js +106 -0
  59. package/dist/storage/types.d.ts +46 -0
  60. package/dist/storage/types.js +1 -0
  61. package/dist/store/indexeddb.d.ts +9 -0
  62. package/dist/store/indexeddb.js +72 -0
  63. package/dist/store/memory.d.ts +15 -0
  64. package/dist/store/memory.js +22 -0
  65. package/dist/store/select.d.ts +15 -0
  66. package/dist/store/select.js +64 -0
  67. package/dist/store/sqlite.d.ts +11 -0
  68. package/dist/store/sqlite.js +68 -0
  69. package/dist/store/types.d.ts +57 -0
  70. package/dist/store/types.js +1 -0
  71. package/dist/tally/tally.d.ts +44 -0
  72. package/dist/tally/tally.js +89 -0
  73. package/dist/tally/types.d.ts +51 -0
  74. package/dist/tally/types.js +13 -0
  75. package/dist/topic.d.ts +20 -0
  76. package/dist/topic.js +28 -0
  77. package/dist/transport/accepted-dedup.d.ts +30 -0
  78. package/dist/transport/accepted-dedup.js +34 -0
  79. package/dist/transport/announce/browser.d.ts +9 -0
  80. package/dist/transport/announce/browser.js +14 -0
  81. package/dist/transport/announce/node.d.ts +38 -0
  82. package/dist/transport/announce/node.js +162 -0
  83. package/dist/transport/announce/types.d.ts +74 -0
  84. package/dist/transport/announce/types.js +16 -0
  85. package/dist/transport/bundle-store.d.ts +11 -0
  86. package/dist/transport/bundle-store.js +34 -0
  87. package/dist/transport/chase.d.ts +125 -0
  88. package/dist/transport/chase.js +182 -0
  89. package/dist/transport/gossip-validator.d.ts +107 -0
  90. package/dist/transport/gossip-validator.js +99 -0
  91. package/dist/transport/helia.d.ts +55 -0
  92. package/dist/transport/helia.js +114 -0
  93. package/dist/transport/integration/harness.d.ts +65 -0
  94. package/dist/transport/integration/harness.js +278 -0
  95. package/dist/transport/messages.d.ts +97 -0
  96. package/dist/transport/messages.js +117 -0
  97. package/dist/transport/rate-limit.d.ts +11 -0
  98. package/dist/transport/rate-limit.js +20 -0
  99. package/dist/transport/transport.d.ts +20 -0
  100. package/dist/transport/transport.js +35 -0
  101. package/dist/transport/types.d.ts +189 -0
  102. package/dist/transport/types.js +1 -0
  103. package/dist/verify/background.d.ts +81 -0
  104. package/dist/verify/background.js +236 -0
  105. package/dist/verify/bundle.d.ts +58 -0
  106. package/dist/verify/bundle.js +84 -0
  107. package/dist/verify/cache.d.ts +48 -0
  108. package/dist/verify/cache.js +62 -0
  109. package/dist/verify/constraints.d.ts +16 -0
  110. package/dist/verify/constraints.js +35 -0
  111. package/dist/verify/gate-result-cache.d.ts +65 -0
  112. package/dist/verify/gate-result-cache.js +91 -0
  113. package/dist/verify/name-resolution-cache.d.ts +59 -0
  114. package/dist/verify/name-resolution-cache.js +64 -0
  115. package/dist/verify/signature.d.ts +9 -0
  116. package/dist/verify/signature.js +55 -0
  117. package/dist/verify/types.d.ts +101 -0
  118. package/dist/verify/types.js +1 -0
  119. 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;