@bitsocial/pubsub-votes 0.0.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.
Files changed (117) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +212 -0
  3. package/dist/chain/bucket.d.ts +13 -0
  4. package/dist/chain/bucket.js +24 -0
  5. package/dist/chain/ticker.d.ts +15 -0
  6. package/dist/chain/ticker.js +25 -0
  7. package/dist/chain/types.d.ts +90 -0
  8. package/dist/chain/types.js +1 -0
  9. package/dist/checkpoint/codec.d.ts +54 -0
  10. package/dist/checkpoint/codec.js +99 -0
  11. package/dist/client/root-puller.d.ts +49 -0
  12. package/dist/client/root-puller.js +140 -0
  13. package/dist/client/voter.d.ts +225 -0
  14. package/dist/client/voter.js +1195 -0
  15. package/dist/crdt/codec.d.ts +41 -0
  16. package/dist/crdt/codec.js +137 -0
  17. package/dist/crdt/crdt.d.ts +22 -0
  18. package/dist/crdt/crdt.js +127 -0
  19. package/dist/crdt/store.d.ts +8 -0
  20. package/dist/crdt/store.js +23 -0
  21. package/dist/crdt/types.d.ts +87 -0
  22. package/dist/crdt/types.js +1 -0
  23. package/dist/encoding/canonical.d.ts +22 -0
  24. package/dist/encoding/canonical.js +26 -0
  25. package/dist/errors.d.ts +72 -0
  26. package/dist/errors.js +111 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.js +39 -0
  29. package/dist/rules/constant.d.ts +14 -0
  30. package/dist/rules/constant.js +18 -0
  31. package/dist/rules/erc20-balance.d.ts +30 -0
  32. package/dist/rules/erc20-balance.js +44 -0
  33. package/dist/rules/erc721-min-balance.d.ts +18 -0
  34. package/dist/rules/erc721-min-balance.js +56 -0
  35. package/dist/rules/registry.d.ts +42 -0
  36. package/dist/rules/registry.js +61 -0
  37. package/dist/rules/types.d.ts +74 -0
  38. package/dist/rules/types.js +1 -0
  39. package/dist/schema/common.d.ts +25 -0
  40. package/dist/schema/common.js +24 -0
  41. package/dist/schema/criteria.d.ts +78 -0
  42. package/dist/schema/criteria.js +76 -0
  43. package/dist/schema/directory.d.ts +42 -0
  44. package/dist/schema/directory.js +52 -0
  45. package/dist/schema/votes.d.ts +55 -0
  46. package/dist/schema/votes.js +116 -0
  47. package/dist/signer/eip712.d.ts +103 -0
  48. package/dist/signer/eip712.js +85 -0
  49. package/dist/signer/types.d.ts +31 -0
  50. package/dist/signer/types.js +1 -0
  51. package/dist/storage/browser.d.ts +3 -0
  52. package/dist/storage/browser.js +110 -0
  53. package/dist/storage/memory.d.ts +10 -0
  54. package/dist/storage/memory.js +56 -0
  55. package/dist/storage/node.d.ts +5 -0
  56. package/dist/storage/node.js +106 -0
  57. package/dist/storage/types.d.ts +46 -0
  58. package/dist/storage/types.js +1 -0
  59. package/dist/store/indexeddb.d.ts +9 -0
  60. package/dist/store/indexeddb.js +72 -0
  61. package/dist/store/memory.d.ts +15 -0
  62. package/dist/store/memory.js +22 -0
  63. package/dist/store/select.d.ts +15 -0
  64. package/dist/store/select.js +64 -0
  65. package/dist/store/sqlite.d.ts +11 -0
  66. package/dist/store/sqlite.js +68 -0
  67. package/dist/store/types.d.ts +57 -0
  68. package/dist/store/types.js +1 -0
  69. package/dist/tally/tally.d.ts +44 -0
  70. package/dist/tally/tally.js +89 -0
  71. package/dist/tally/types.d.ts +51 -0
  72. package/dist/tally/types.js +13 -0
  73. package/dist/topic.d.ts +20 -0
  74. package/dist/topic.js +28 -0
  75. package/dist/transport/accepted-dedup.d.ts +30 -0
  76. package/dist/transport/accepted-dedup.js +34 -0
  77. package/dist/transport/announce/browser.d.ts +9 -0
  78. package/dist/transport/announce/browser.js +14 -0
  79. package/dist/transport/announce/node.d.ts +38 -0
  80. package/dist/transport/announce/node.js +162 -0
  81. package/dist/transport/announce/types.d.ts +74 -0
  82. package/dist/transport/announce/types.js +16 -0
  83. package/dist/transport/bundle-store.d.ts +11 -0
  84. package/dist/transport/bundle-store.js +34 -0
  85. package/dist/transport/chase.d.ts +83 -0
  86. package/dist/transport/chase.js +88 -0
  87. package/dist/transport/gossip-validator.d.ts +107 -0
  88. package/dist/transport/gossip-validator.js +99 -0
  89. package/dist/transport/helia.d.ts +41 -0
  90. package/dist/transport/helia.js +98 -0
  91. package/dist/transport/integration/harness.d.ts +60 -0
  92. package/dist/transport/integration/harness.js +262 -0
  93. package/dist/transport/messages.d.ts +97 -0
  94. package/dist/transport/messages.js +117 -0
  95. package/dist/transport/rate-limit.d.ts +11 -0
  96. package/dist/transport/rate-limit.js +20 -0
  97. package/dist/transport/transport.d.ts +20 -0
  98. package/dist/transport/transport.js +35 -0
  99. package/dist/transport/types.d.ts +165 -0
  100. package/dist/transport/types.js +1 -0
  101. package/dist/verify/background.d.ts +81 -0
  102. package/dist/verify/background.js +236 -0
  103. package/dist/verify/bundle.d.ts +58 -0
  104. package/dist/verify/bundle.js +84 -0
  105. package/dist/verify/cache.d.ts +48 -0
  106. package/dist/verify/cache.js +62 -0
  107. package/dist/verify/constraints.d.ts +16 -0
  108. package/dist/verify/constraints.js +35 -0
  109. package/dist/verify/gate-result-cache.d.ts +65 -0
  110. package/dist/verify/gate-result-cache.js +91 -0
  111. package/dist/verify/name-resolution-cache.d.ts +59 -0
  112. package/dist/verify/name-resolution-cache.js +64 -0
  113. package/dist/verify/signature.d.ts +9 -0
  114. package/dist/verify/signature.js +55 -0
  115. package/dist/verify/types.d.ts +101 -0
  116. package/dist/verify/types.js +1 -0
  117. package/package.json +77 -0
@@ -0,0 +1,60 @@
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
+ /** True once a heard root matched this node's own current root (heartbeat suppression signal). */
35
+ heardMatchingRoot(): boolean;
36
+ /** Replace the injected verifier (e.g. a rejecter, or a slow one, for a specific assertion). */
37
+ setVerifier(verify: (bundle: VotesBundle) => Promise<BundleVerdict>): void;
38
+ /** Encode this node's current winner-set to a checkpoint (blocks written to its blockstore). */
39
+ checkpointRootRecord(): Promise<RootRecord>;
40
+ /** Publish this node's own root record on the topic (a heartbeat). */
41
+ publishOwnRoot(): Promise<void>;
42
+ /** Seed a bundle straight into this node's state (store block + CRDT merge), no network. */
43
+ admitBundle(bundle: VotesBundle): Promise<void>;
44
+ stop(): Promise<void>;
45
+ }
46
+ /** Build one real node with the full forward-gate wired to real gossipsub. */
47
+ export declare function makeVoteNode(topic: string, options?: VoteNodeOptions): Promise<VoteNode>;
48
+ /**
49
+ * Dial `b` from `a` and wait until gossipsub has grafted them into each other's mesh for
50
+ * `topic` — after which a publish is reliably delivered (so negative assertions like "not
51
+ * forwarded" are meaningful, not just races against an unformed mesh).
52
+ */
53
+ export declare function connectNodes(a: VoteNode, b: VoteNode): Promise<void>;
54
+ /** Poll `predicate` until it is truthy or `timeoutMs` elapses (then throw with `description`). */
55
+ export declare function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs?: number, description?: string): Promise<void>;
56
+ /** Resolve after `ms`; used only to space out polls, never as an observation substitute. */
57
+ export declare function delay(ms: number): Promise<void>;
58
+ /** A well-formed one-vote bundle. The signature is a well-formed 65 bytes; validity is the verifier's job. */
59
+ export declare function sampleBundle(address: string, publicKey: string, blockNumber?: number): VotesBundle;
60
+ export {};
@@ -0,0 +1,262 @@
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 } 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 chaseLimit = pLimit(2);
131
+ const chaser = makeRootChaser({
132
+ getBlock: async (cid, signal) => {
133
+ try {
134
+ return await blockstore.get(cid, { signal });
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ },
140
+ verifyOffline: (bundle) => verifier.verifyOffline(bundle),
141
+ cache,
142
+ hasBundle: (cid) => store.has(cid),
143
+ admit,
144
+ // The harness runs the whole swapped pipeline in `verifyOffline` above, so nothing is
145
+ // left deferred; the background verifier has its own unit tests.
146
+ deferVerify: () => { },
147
+ limit: (fn) => chaseLimit(fn),
148
+ timeoutMs: 30_000
149
+ });
150
+ const gateLimit = pLimit(8);
151
+ const gate = makeGossipGate({
152
+ decodeMessage: decodeVoteMessage,
153
+ parseBundle: async (blockBytes) => ({
154
+ cid: await bundleCidForBytes(blockBytes),
155
+ bundle: decodeBundle(blockBytes)
156
+ }),
157
+ verifier,
158
+ cache,
159
+ admit,
160
+ limit: (fn) => gateLimit(fn),
161
+ allowBundlePeer: () => true,
162
+ allowRootPeer: () => true,
163
+ onAccept: (_cid, _bundle, _from) => { },
164
+ // Mirror `PubsubVoter.#handleRootRecord`: a matching root is the suppression signal (no
165
+ // chase, no echo); a divergent root is chased over directed bitswap.
166
+ onRootRecord: (record) => {
167
+ heardRoots.push(record);
168
+ void (async () => {
169
+ const own = await checkpointRootRecord();
170
+ if (own.root.equals(record.root)) {
171
+ matchedOwnRoot = true;
172
+ return;
173
+ }
174
+ chaser.chase(record.root);
175
+ })().catch(() => { });
176
+ },
177
+ maxBundleMessageBytes: maxBundleMessageBytes({ maxVotesPerAddress: 1 }),
178
+ maxRootMessageBytes: MAX_ROOT_MESSAGE_BYTES,
179
+ timeoutMs
180
+ });
181
+ const transport = makeVoteTransport({ pubsub, topic, gate });
182
+ await transport.start();
183
+ // Record every ACCEPTED message. gossipsub emits "message" only after the topic validator
184
+ // returns accept, so a delivered bundle is one this node forwarded — and a rejected/ignored
185
+ // one never appears here. This is the observable for the forward / no-forward assertions.
186
+ pubsub.addEventListener("message", (evt) => {
187
+ if (evt.detail.topic !== topic)
188
+ return;
189
+ try {
190
+ const message = decodeVoteMessage(evt.detail.data);
191
+ if (message.kind === "bundle")
192
+ acceptedBundles.push(message.bundle);
193
+ }
194
+ catch {
195
+ // A delivered message always decodes (it passed the gate); ignore anything else.
196
+ }
197
+ });
198
+ return {
199
+ helia,
200
+ libp2p,
201
+ pubsub,
202
+ peerId: libp2p.peerId.toString(),
203
+ topic,
204
+ crdt,
205
+ cache,
206
+ chaser,
207
+ transport,
208
+ acceptedBundles,
209
+ heardRoots,
210
+ heardMatchingRoot: () => matchedOwnRoot,
211
+ setVerifier: (verify) => {
212
+ verifyImpl = verify;
213
+ },
214
+ checkpointRootRecord,
215
+ publishOwnRoot: async () => {
216
+ await transport.publishRootRecord(await checkpointRootRecord());
217
+ },
218
+ admitBundle: async (bundle) => {
219
+ const bytes = encodeBundle(bundle);
220
+ const cid = await bundleCidForBytes(bytes);
221
+ await blockstore.put(cid, bytes);
222
+ await crdt.merge([cid]);
223
+ },
224
+ stop: async () => {
225
+ await transport.stop();
226
+ await helia.stop();
227
+ }
228
+ };
229
+ }
230
+ /**
231
+ * Dial `b` from `a` and wait until gossipsub has grafted them into each other's mesh for
232
+ * `topic` — after which a publish is reliably delivered (so negative assertions like "not
233
+ * forwarded" are meaningful, not just races against an unformed mesh).
234
+ */
235
+ export async function connectNodes(a, b) {
236
+ await a.libp2p.dial(b.libp2p.getMultiaddrs());
237
+ 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");
238
+ }
239
+ /** Poll `predicate` until it is truthy or `timeoutMs` elapses (then throw with `description`). */
240
+ export async function waitFor(predicate, timeoutMs = 15_000, description = "condition") {
241
+ const deadline = Date.now() + timeoutMs;
242
+ for (;;) {
243
+ if (await predicate())
244
+ return;
245
+ if (Date.now() > deadline)
246
+ throw new Error(`timed out after ${timeoutMs}ms waiting for ${description}`);
247
+ await delay(50);
248
+ }
249
+ }
250
+ /** Resolve after `ms`; used only to space out polls, never as an observation substitute. */
251
+ export function delay(ms) {
252
+ return new Promise((resolve) => setTimeout(resolve, ms));
253
+ }
254
+ /** A well-formed one-vote bundle. The signature is a well-formed 65 bytes; validity is the verifier's job. */
255
+ export function sampleBundle(address, publicKey, blockNumber = 10) {
256
+ return {
257
+ address,
258
+ votes: [{ community: { publicKey }, vote: 1 }],
259
+ blockNumber,
260
+ signature: { signature: `0x${"11".repeat(65)}`, type: "eip712" }
261
+ };
262
+ }
@@ -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;
@@ -0,0 +1,117 @@
1
+ import { CID } from "multiformats/cid";
2
+ import * as dagCbor from "@ipld/dag-cbor";
3
+ import { z } from "zod";
4
+ import { encodeCanonical } from "../encoding/canonical.js";
5
+ /**
6
+ * The pubsub message payload: a two-kind discriminated union (see DESIGN.md "Transport").
7
+ *
8
+ * - `bundle`: one wallet's own bundle as a **live delta** — the exact binary bundle-block
9
+ * bytes (crdt/codec.ts) inlined, so the receiver validates straight from the message,
10
+ * hashing the embedded bytes yields the bundle CID (the verdict-cache key), and the
11
+ * blockstore put is byte-identical. No fetch toward the publisher exists on this path.
12
+ * - `root`: the constant-size **root record** `{ version, root, count, sizeBytes }` — the
13
+ * checkpoint heartbeat (see DESIGN.md "Checkpoints"). An unverifiable *hint*, never
14
+ * trusted; the same record also travels over the libp2p fetch protocol, so its codec is
15
+ * standalone (`encodeRootRecord`/`decodeRootRecord`).
16
+ *
17
+ * A message carries **no authority** either way: all trust comes from re-verifying the
18
+ * self-authenticating bundle (or the self-verifying blocks behind a root) before acting.
19
+ * Encoding is the same canonical dag-cbor as the rest of the protocol; the layout is pinned
20
+ * by fixed test vectors in `messages.test.ts` — any change is a breaking wire change.
21
+ */
22
+ /** Envelope wire version (the root record carries its own `version` for the fetch path). */
23
+ export const MESSAGE_VERSION = 1;
24
+ export const ROOT_RECORD_VERSION = 1;
25
+ /** See `z.custom` note in crdt/codec.ts — `z.instanceof(Uint8Array)` over-narrows the buffer type. */
26
+ const BytesSchema = z.custom((value) => value instanceof Uint8Array, "expected bytes");
27
+ const CidSchema = z.custom((value) => CID.asCID(value) !== null, "expected a CID link");
28
+ const RootRecordSchema = z.strictObject({
29
+ version: z.number().int().positive(),
30
+ // Points to a `CheckpointRoot` block (`{ chunks: CID[] }`, checkpoint/codec.ts).
31
+ root: CidSchema,
32
+ count: z.number().int().nonnegative(),
33
+ sizeBytes: z.number().int().nonnegative()
34
+ });
35
+ const FetchRootRecordSchema = z.strictObject({
36
+ version: z.number().int().positive(),
37
+ root: CidSchema,
38
+ chunks: z.array(CidSchema),
39
+ count: z.number().int().nonnegative(),
40
+ sizeBytes: z.number().int().nonnegative()
41
+ });
42
+ const MessageSchema = z.discriminatedUnion("kind", [
43
+ z.strictObject({ v: z.literal(MESSAGE_VERSION), kind: z.literal("bundle"), bundle: BytesSchema }),
44
+ z.strictObject({ v: z.literal(MESSAGE_VERSION), kind: z.literal("root"), record: RootRecordSchema })
45
+ ]);
46
+ /** Project any root record (possibly a {@link FetchRootRecord}) to the bare advertisement fields. */
47
+ function toRootRecord(record) {
48
+ return { version: record.version, root: record.root, count: record.count, sizeBytes: record.sizeBytes };
49
+ }
50
+ /** Encode one bundle's binary block bytes as a live-delta message. */
51
+ export function encodeBundleMessage(blockBytes) {
52
+ return encodeCanonical({ v: MESSAGE_VERSION, kind: "bundle", bundle: blockBytes });
53
+ }
54
+ /**
55
+ * Encode a root record as a heartbeat message. The broadcast heartbeat carries only the bare
56
+ * advertisement (never the fetch response's chunk index), so its size stays a tiny fixed
57
+ * constant — accepts a {@link FetchRootRecord} and drops the index (see {@link FetchRootRecord}).
58
+ */
59
+ export function encodeRootMessage(record) {
60
+ return encodeCanonical({ v: MESSAGE_VERSION, kind: "root", record: RootRecordSchema.parse(toRootRecord(record)) });
61
+ }
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 function decodeVoteMessage(data) {
67
+ const parsed = MessageSchema.parse(dagCbor.decode(data));
68
+ return parsed.kind === "bundle" ? { kind: "bundle", bundle: parsed.bundle } : { kind: "root", record: parsed.record };
69
+ }
70
+ /**
71
+ * The libp2p fetch-protocol key suffix for a topic's root record: the full key is
72
+ * `topic + "/root"` (the topic prefix only namespaces *which* contest a multi-contest
73
+ * responder is asked about — it is not a pubsub topic). See DESIGN.md "Checkpoints".
74
+ */
75
+ export const ROOT_FETCH_KEY_SUFFIX = "/root";
76
+ /** The fetch-protocol key for one contest's root record. */
77
+ export function rootFetchKey(topic) {
78
+ return `${topic}${ROOT_FETCH_KEY_SUFFIX}`;
79
+ }
80
+ /**
81
+ * Standalone root-record codec — the record served over the libp2p fetch protocol, carrying the
82
+ * chunk-CID index so a cold joiner can skip the root-manifest round-trip (see {@link FetchRootRecord}).
83
+ */
84
+ export function encodeRootRecord(record) {
85
+ return encodeCanonical(FetchRootRecordSchema.parse(record));
86
+ }
87
+ /** Decode a fetch-protocol root-record value (with its chunk index); throws on malformed. */
88
+ export function decodeRootRecord(bytes) {
89
+ return FetchRootRecordSchema.parse(dagCbor.decode(bytes));
90
+ }
91
+ /**
92
+ * The fixed byte cap for a root-kind message. The record is ~100 B by construction (version +
93
+ * CID link + two small ints + envelope), so this is a generous constant — anything larger is
94
+ * provably not a well-formed root message.
95
+ */
96
+ export const MAX_ROOT_MESSAGE_BYTES = 256;
97
+ /** Envelope + fixed bundle fields (address, blockNumber, signature, structure), generously. */
98
+ const BUNDLE_MESSAGE_OVERHEAD_BYTES = 256;
99
+ /**
100
+ * One vote entry's ceiling: a 253-byte name (the schema's DNS bound), a raw-multihash
101
+ * publicKey (≲64 B for any realistic key hash), a small int vote, and map structure —
102
+ * rounded up generously. Generosity is safe: the cap exists to bound *adversarial* payloads,
103
+ * and what matters is that every peer derives the identical number from the criteria.
104
+ */
105
+ const MAX_VOTE_ENTRY_BYTES = 512;
106
+ /**
107
+ * The derived per-message cap for a bundle-kind message — a pure function of the criteria
108
+ * (see DESIGN.md "Message size cap"): the criteria's own `maxVotesPerAddress` bounds the
109
+ * entry count, the schema's fixed field bounds (253-byte name, binary crypto fields) bound
110
+ * the entry size, so every peer computes the same cap from the same criteria bytes and an
111
+ * over-cap `reject` stays deterministic, penalizable, and cacheable. Deliberately NOT a
112
+ * criteria field: a raw byte knob could contradict `maxVotesPerAddress` and reject valid
113
+ * bundles.
114
+ */
115
+ export function maxBundleMessageBytes(criteria) {
116
+ return BUNDLE_MESSAGE_OVERHEAD_BYTES + MAX_VOTE_ENTRY_BYTES * criteria.maxVotesPerAddress;
117
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A per-peer fixed-window rate gate for the forward-gate. Bounds how many messages one peer
3
+ * can make us validate per window, capping the resource cost of a flood of plausible-looking
4
+ * announcements (the residual "resource exhaustion, not incorrectness" concern in DESIGN.md
5
+ * "Transport"). Over-rate returns `false`, which the gate maps to `ignore` — dropped without
6
+ * a peer-score penalty, since being briefly over a local rate is not provable misbehavior.
7
+ */
8
+ export declare function makeRateLimiter(opts: {
9
+ limit: number;
10
+ intervalMs: number;
11
+ }): (peer: string) => boolean;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A per-peer fixed-window rate gate for the forward-gate. Bounds how many messages one peer
3
+ * can make us validate per window, capping the resource cost of a flood of plausible-looking
4
+ * announcements (the residual "resource exhaustion, not incorrectness" concern in DESIGN.md
5
+ * "Transport"). Over-rate returns `false`, which the gate maps to `ignore` — dropped without
6
+ * a peer-score penalty, since being briefly over a local rate is not provable misbehavior.
7
+ */
8
+ export function makeRateLimiter(opts) {
9
+ const windows = new Map();
10
+ return (peer) => {
11
+ const now = Date.now();
12
+ const window = windows.get(peer);
13
+ if (!window || now - window.start >= opts.intervalMs) {
14
+ windows.set(peer, { count: 1, start: now });
15
+ return true;
16
+ }
17
+ window.count += 1;
18
+ return window.count <= opts.limit;
19
+ };
20
+ }
@@ -0,0 +1,20 @@
1
+ import type { PubsubService, VoteTransport } from "./types.js";
2
+ import type { GossipGate } from "./gossip-validator.js";
3
+ /**
4
+ * The live transport: wires the host's gossipsub to the forward-gate. This is the only place
5
+ * the async validator is installed on a real topic; the decision logic lives in the pure
6
+ * {@link GossipGate} (gossip-validator.ts), so this module is thin glue.
7
+ *
8
+ * On `start` it installs the gate as the topic validator (gossipsub awaits it before
9
+ * re-forwarding — see DESIGN.md "Transport") and subscribes. Publishing is the live-delta
10
+ * model: one inline bundle per message (a new vote, a client re-publish, or a withdrawal), or a
11
+ * root-record heartbeat. Cold-start root pulls ride the libp2p fetch protocol, not pubsub
12
+ * (see DESIGN.md "Checkpoints").
13
+ */
14
+ export interface VoteTransportDeps {
15
+ pubsub: PubsubService;
16
+ topic: string;
17
+ /** The forward-gate; validates + merges accepted bundles, surfaces root records. */
18
+ gate: GossipGate;
19
+ }
20
+ export declare function makeVoteTransport(deps: VoteTransportDeps): VoteTransport;
@@ -0,0 +1,35 @@
1
+ import { encodeBundleMessage, encodeRootMessage } from "./messages.js";
2
+ export function makeVoteTransport(deps) {
3
+ const { pubsub, topic, gate } = deps;
4
+ // The installed gossipsub validator: filter to our topic and run the gate. Returning a
5
+ // promise makes gossipsub await the full pipeline before forwarding. Accepted bundles are
6
+ // merged (and root records surfaced) by the gate's own callbacks.
7
+ const validator = async (peer, message) => {
8
+ if (message.topic !== topic)
9
+ return "ignore";
10
+ return gate.validate(message.data, peer.toString());
11
+ };
12
+ return {
13
+ async start() {
14
+ if (!pubsub.topicValidators) {
15
+ throw new Error("the injected pubsub has no `topicValidators` map; a gossipsub service is required to " +
16
+ "install the validate-before-forward gate. See DESIGN.md \"Transport\".");
17
+ }
18
+ pubsub.topicValidators.set(topic, validator);
19
+ pubsub.subscribe(topic);
20
+ },
21
+ async stop() {
22
+ pubsub.topicValidators?.delete(topic);
23
+ pubsub.unsubscribe(topic);
24
+ },
25
+ async publishBundle(blockBytes) {
26
+ // gossipsub resolves `{ recipients }`; a non-gossipsub pubsub may resolve nothing —
27
+ // fall back to 0 rather than assume the shape.
28
+ const result = await pubsub.publish(topic, encodeBundleMessage(blockBytes));
29
+ return { recipientCount: result?.recipients?.length ?? 0 };
30
+ },
31
+ async publishRootRecord(record) {
32
+ await pubsub.publish(topic, encodeRootMessage(record));
33
+ }
34
+ };
35
+ }