@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.
- package/LICENSE +674 -0
- package/README.md +212 -0
- package/dist/chain/bucket.d.ts +13 -0
- package/dist/chain/bucket.js +24 -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 +225 -0
- package/dist/client/voter.js +1195 -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 +72 -0
- package/dist/errors.js +111 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +39 -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 +56 -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 +83 -0
- package/dist/transport/chase.js +88 -0
- package/dist/transport/gossip-validator.d.ts +107 -0
- package/dist/transport/gossip-validator.js +99 -0
- package/dist/transport/helia.d.ts +41 -0
- package/dist/transport/helia.js +98 -0
- package/dist/transport/integration/harness.d.ts +60 -0
- package/dist/transport/integration/harness.js +262 -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 +165 -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 +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
|
+
}
|