@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,74 @@
1
+ /**
2
+ * Provider-record announcer types (see DESIGN.md "Deferred pkc-js work", provider-record
3
+ * announces). The announcer is the write side of the delegated-routing seam: querying rides the
4
+ * injected node's `libp2p.contentRouting` (the host wires its routers into `libp2p.services`), but
5
+ * the pinned js stack has NO working announce path — `@helia/delegated-routing-v1-http-api-client`'s
6
+ * `provide()` is a literal noop — so provider records only get written by a direct
7
+ * `PUT /routing/v1/providers` to each router. This library is the node that actually holds the
8
+ * state (topic membership, fetch responder, blockstore), so its records are always truthful and
9
+ * current; `PubsubVoterOptions.httpRouterUrls` exists ONLY because announcing cannot ride the
10
+ * `contentRouting` seam.
11
+ *
12
+ * The implementation is Node-only: the browser build swaps `node.js` for the inert `browser.js`
13
+ * stub via the package.json `browser` field (the same remap as `src/storage/`), because a browser
14
+ * peer is not dialable and must never announce.
15
+ */
16
+ /**
17
+ * The slice of the injected node's libp2p the announcer reads, declared structurally (a running
18
+ * `Libp2p` satisfies it). `self:peer:update` fires when the node's address set changes — an
19
+ * AutoTLS certificate landing, a WebRTC-Direct certhash rotation — and triggers a re-announce so
20
+ * records never carry stale addresses for more than a debounce window.
21
+ */
22
+ export interface AnnouncerLibp2p {
23
+ peerId: {
24
+ toString(): string;
25
+ };
26
+ getMultiaddrs(): Array<{
27
+ toString(): string;
28
+ }>;
29
+ addEventListener(type: "self:peer:update", listener: () => void): void;
30
+ removeEventListener(type: "self:peer:update", listener: () => void): void;
31
+ }
32
+ export interface AnnouncerOptions {
33
+ /** Delegated Routing V1 base URLs to PUT provider records to. Empty means never announce. */
34
+ routerUrls: readonly string[];
35
+ /** The injected node's libp2p (peer id, current addresses, address-change events). */
36
+ libp2p: AnnouncerLibp2p;
37
+ /**
38
+ * The CIDs to announce, collected fresh per tick: every joined contest's criteria CID plus its
39
+ * current checkpoint root + chunk CIDs, batched into ONE record (`Keys` is an array) so a
40
+ * directory host costs one request per router per tick. An empty list skips the tick.
41
+ */
42
+ keys(): Promise<string[]>;
43
+ /** Re-announce cadence (ms). Test seam; defaults to hourly (see `ANNOUNCE_INTERVAL_MS`). */
44
+ intervalMs?: number;
45
+ /** Change-coalescing window (ms). Test seam; defaults to `ANNOUNCE_DEBOUNCE_MS`. */
46
+ debounceMs?: number;
47
+ /** Per-router PUT deadline (ms). Test seam; defaults to `ANNOUNCE_ROUTER_TIMEOUT_MS`. */
48
+ timeoutMs?: number;
49
+ /**
50
+ * Per-router failure hook (timeout, non-2xx, network error). Purely observational — a failing
51
+ * router never throws into the voter and is not retried; the next tick covers it.
52
+ */
53
+ onError?(url: string, error: unknown): void;
54
+ }
55
+ /** The voter-facing announcer handle. All methods are synchronous and idempotent. */
56
+ export interface Announcer {
57
+ /**
58
+ * Begin announcing: subscribe to address changes and arm the periodic re-announce. Called when
59
+ * the voter's first topic joins (symmetric with the lazy fetch-responder lifecycle). Does not
60
+ * announce by itself — the join that triggered it also calls {@link notifyChange}.
61
+ */
62
+ start(): void;
63
+ /**
64
+ * Stop announcing and drop timers/listeners. Called when the last topic is left. Records
65
+ * already written age out by the router's TTL; there is no un-announce.
66
+ */
67
+ stop(): void;
68
+ /**
69
+ * Coalesced re-announce trigger: a contest joined, a checkpoint root changed, or the address
70
+ * set changed. The first call arms one debounce timer; further calls within the window ride
71
+ * it, so a gossip burst (or a directory-wide join) costs one announce.
72
+ */
73
+ notifyChange(): void;
74
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Provider-record announcer types (see DESIGN.md "Deferred pkc-js work", provider-record
3
+ * announces). The announcer is the write side of the delegated-routing seam: querying rides the
4
+ * injected node's `libp2p.contentRouting` (the host wires its routers into `libp2p.services`), but
5
+ * the pinned js stack has NO working announce path — `@helia/delegated-routing-v1-http-api-client`'s
6
+ * `provide()` is a literal noop — so provider records only get written by a direct
7
+ * `PUT /routing/v1/providers` to each router. This library is the node that actually holds the
8
+ * state (topic membership, fetch responder, blockstore), so its records are always truthful and
9
+ * current; `PubsubVoterOptions.httpRouterUrls` exists ONLY because announcing cannot ride the
10
+ * `contentRouting` seam.
11
+ *
12
+ * The implementation is Node-only: the browser build swaps `node.js` for the inert `browser.js`
13
+ * stub via the package.json `browser` field (the same remap as `src/storage/`), because a browser
14
+ * peer is not dialable and must never announce.
15
+ */
16
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { BundleStore } from "../crdt/types.js";
2
+ import type { BlockstoreLike } from "./types.js";
3
+ /**
4
+ * A {@link BundleStore} backed by the host's Helia blockstore. `put` writes a bundle as a
5
+ * dag-cbor block locally; `get` reads by CID — and because bitswap retrieves through the
6
+ * blockstore, an unknown CID is fetched from peers. A fetch that fails or times out surfaces
7
+ * as `undefined` (not a throw), which the forward-gate reads as "unfetchable -> ignore"
8
+ * rather than a provable fault. Lives in transport/ (not crdt/) because it touches the
9
+ * blockstore; the CRDT stays libp2p-free.
10
+ */
11
+ export declare function makeBlockstoreBundleStore(blockstore: BlockstoreLike): BundleStore;
@@ -0,0 +1,34 @@
1
+ import { encodeBundle, decodeBundle, bundleCid } from "../crdt/codec.js";
2
+ /**
3
+ * A {@link BundleStore} backed by the host's Helia blockstore. `put` writes a bundle as a
4
+ * dag-cbor block locally; `get` reads by CID — and because bitswap retrieves through the
5
+ * blockstore, an unknown CID is fetched from peers. A fetch that fails or times out surfaces
6
+ * as `undefined` (not a throw), which the forward-gate reads as "unfetchable -> ignore"
7
+ * rather than a provable fault. Lives in transport/ (not crdt/) because it touches the
8
+ * blockstore; the CRDT stays libp2p-free.
9
+ */
10
+ export function makeBlockstoreBundleStore(blockstore) {
11
+ return {
12
+ async put(bundle) {
13
+ const cid = await bundleCid(bundle);
14
+ await blockstore.put(cid, encodeBundle(bundle));
15
+ return cid;
16
+ },
17
+ async get(cid, options) {
18
+ try {
19
+ return decodeBundle(await blockstore.get(cid, options));
20
+ }
21
+ catch {
22
+ return undefined; // unfetchable / undecodable / aborted — gate treats as ignore
23
+ }
24
+ },
25
+ async has(cid) {
26
+ try {
27
+ return await blockstore.has(cid);
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,83 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ import type { VerdictCache } from "../verify/cache.js";
4
+ import type { VerifyResult } from "../verify/types.js";
5
+ import type { PendingBundle } from "../verify/background.js";
6
+ /**
7
+ * The divergence chase: act on an advertised checkpoint root that differs from our own (a
8
+ * heartbeat or fetch-protocol hint — see DESIGN.md "Checkpoints", "Block pull"). Pull the
9
+ * blocks behind the root by CID (directed bitswap at the connected advertisers, through the
10
+ * injected `getBlock`) and admit each inlined bundle in two stages:
11
+ *
12
+ * 1. **Offline, synchronous, before admit** (µs each): signature + criteria constraints via
13
+ * `verifyOffline` — a forged or malformed bundle dies here and is never admitted.
14
+ * 2. **Network, deferred**: the on-chain gate read and name resolution run in the background
15
+ * chain verifier (`deferVerify`, one batch per chased root so the gate reads batch into
16
+ * as few RPC round trips as the rule allows), which confirms or evicts each bundle after
17
+ * the fact. This is what keeps a cold join non-blocking: a 100-vote checkpoint admits in
18
+ * milliseconds instead of serializing 100 chain reads (see DESIGN.md "Background chain
19
+ * verification").
20
+ *
21
+ * So a single liar cannot inject a forged vote (offline check) nor hide an honest one
22
+ * (union-only merge); an *ineligible-wallet* bundle it injects is admitted provisionally,
23
+ * surfaces only as a `chainVerified: false` tally row, is never re-served in our checkpoint,
24
+ * and is evicted as soon as its batched gate read lands.
25
+ *
26
+ * Bounded like everything at the gate: a shared concurrency cap (`limit`), a per-root
27
+ * deadline whose `AbortSignal` cancels in-flight block wants, and in-flight dedup so a spray
28
+ * of the same root queues one chase, not many. A failed chase (unfetchable blocks, malformed
29
+ * chunks, all-invalid bundles) contributes nothing and throws nothing — the hint was never
30
+ * trusted. No libp2p import; pure seams, unit-testable offline.
31
+ */
32
+ export interface RootChaserDeps {
33
+ /**
34
+ * Fetch one checkpoint block by CID (blockstore + directed bitswap); `undefined` or a
35
+ * throw means unavailable. `signal` aborts the want when the per-root deadline fires.
36
+ */
37
+ getBlock: (cid: CID, signal: AbortSignal) => Promise<Uint8Array | undefined>;
38
+ /** Stage 1 only — signature + constraints, local and synchronous (the gate's same stage). */
39
+ verifyOffline: (bundle: VotesBundle) => Promise<VerifyResult>;
40
+ /** The gate's per-CID verdict cache, shared so chased bundles reuse (and feed) it. */
41
+ cache: VerdictCache;
42
+ /** The gate's freshness guard (see gossip-validator.ts); omitted ⇒ no check. */
43
+ isEvaluableNow?: (bundle: VotesBundle) => Promise<boolean>;
44
+ /** Skip bundles we already hold (their CID is in the store) without re-verifying. */
45
+ hasBundle: (cid: CID) => Promise<boolean>;
46
+ /**
47
+ * Store an offline-valid bundle's block bytes and admit its CID into the CRDT (idempotent).
48
+ * `verified: true` means a cached terminal verdict already covers the FULL pipeline (the
49
+ * bundle was verified before, e.g. by the forward-gate); `verified: false` is a provisional
50
+ * admit whose deferred checks ride `deferVerify`.
51
+ */
52
+ admit: (args: {
53
+ cid: CID;
54
+ bytes: Uint8Array;
55
+ bundle: VotesBundle;
56
+ verified: boolean;
57
+ }) => Promise<void>;
58
+ /** Hand one chased root's provisionally admitted bundles to the background chain verifier. */
59
+ deferVerify: (entries: PendingBundle[]) => void;
60
+ /** Called once per chase that admitted at least one bundle (drives tally updates). */
61
+ onMerged?: () => void;
62
+ /** Concurrency limiter shared across chases (a root spray queues, never floods). */
63
+ limit: <T>(fn: () => Promise<T>) => Promise<T>;
64
+ /** Per-root deadline (ms); on expiry the abort signal fires and the chase yields nothing. */
65
+ timeoutMs: number;
66
+ }
67
+ export interface RootChaser {
68
+ /**
69
+ * Chase one advertised root, fire-and-forget: never throws, never blocks the caller
70
+ * (the validator hands hints here without awaiting). A root already being chased is
71
+ * dropped — the in-flight run covers it.
72
+ *
73
+ * `chunks` is the optional piggybacked chunk-CID index from a fetch-protocol root record
74
+ * (see DESIGN.md "Block pull"): when supplied and it re-derives to `root`, the chase skips
75
+ * the root-manifest bitswap round-trip and pulls the chunks directly. A heartbeat hint (no
76
+ * index) omits it and takes the manifest-fetch path. The index is verified against `root`,
77
+ * so a bad one simply falls back — never a trust vector.
78
+ */
79
+ chase(root: CID, chunks?: CID[]): void;
80
+ /** Roots currently being chased (for tests/introspection). */
81
+ inFlight(): number;
82
+ }
83
+ export declare function makeRootChaser(deps: RootChaserDeps): RootChaser;
@@ -0,0 +1,88 @@
1
+ import { decodeCheckpoint } from "../checkpoint/codec.js";
2
+ import { encodeBundle, bundleCidForBytes } from "../crdt/codec.js";
3
+ export function makeRootChaser(deps) {
4
+ const { getBlock, verifyOffline, cache, isEvaluableNow, hasBundle, admit, deferVerify, onMerged, limit, timeoutMs } = deps;
5
+ const inFlight = new Set();
6
+ async function runChase(root, chunks) {
7
+ const controller = new AbortController();
8
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
9
+ try {
10
+ // Race the decode against the deadline so even a `getBlock` that ignores its abort
11
+ // signal cannot pin this chase slot past `timeoutMs` — the slot is always freed.
12
+ const winners = await Promise.race([
13
+ decodeCheckpoint(root, async (cid) => {
14
+ if (controller.signal.aborted)
15
+ return undefined;
16
+ try {
17
+ return await getBlock(cid, controller.signal);
18
+ }
19
+ catch {
20
+ return undefined; // unfetchable/aborted — decode throws "unavailable", chase yields nothing
21
+ }
22
+ }, chunks),
23
+ new Promise((resolve) => {
24
+ controller.signal.addEventListener("abort", () => resolve(undefined), { once: true });
25
+ })
26
+ ]);
27
+ if (winners === undefined)
28
+ return; // deadline hit — the hint contributed nothing
29
+ let merged = false;
30
+ const pending = [];
31
+ for (const bundle of winners) {
32
+ if (controller.signal.aborted)
33
+ break;
34
+ // Re-encoding the decoded bundle reproduces the exact block bytes (the codec is
35
+ // canonical), so the CID matches the advertiser's block and dedups everywhere.
36
+ const bytes = encodeBundle(bundle);
37
+ const cid = await bundleCidForBytes(bytes);
38
+ if (await hasBundle(cid))
39
+ continue; // already held — nothing to verify
40
+ const cached = cache.get(cid);
41
+ if (cached) {
42
+ if (!cached.valid)
43
+ continue; // known bad — skip
44
+ await admit({ cid, bytes, bundle, verified: true }); // full pipeline already passed
45
+ merged = true;
46
+ continue;
47
+ }
48
+ if (isEvaluableNow && !(await isEvaluableNow(bundle)))
49
+ continue; // transient, uncached
50
+ const offline = await verifyOffline(bundle);
51
+ if (!offline.valid) {
52
+ // A liar's forged/malformed bundle dies here, before admit. A provable
53
+ // offline reject (bad signature, constraints) is terminal — cache it so a
54
+ // re-served copy short-circuits; a transient `ignore` stays uncached.
55
+ if (offline.disposition === "reject")
56
+ cache.set(cid, offline);
57
+ continue;
58
+ }
59
+ await admit({ cid, bytes, bundle, verified: false });
60
+ pending.push({ cid, bundle });
61
+ merged = true;
62
+ }
63
+ // One batch per chased root: the background verifier groups these by sample block
64
+ // and batches the gate reads (see verify/background.ts).
65
+ if (pending.length > 0)
66
+ deferVerify(pending);
67
+ if (merged)
68
+ onMerged?.();
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+ return {
75
+ chase(root, chunks) {
76
+ const key = root.toString();
77
+ if (inFlight.has(key))
78
+ return; // the in-flight run covers this hint (chunks derive from root)
79
+ inFlight.add(key);
80
+ void limit(() => runChase(root, chunks))
81
+ .catch(() => { }) // a failed chase contributes nothing; the hint was never trusted
82
+ .finally(() => inFlight.delete(key));
83
+ },
84
+ inFlight() {
85
+ return inFlight.size;
86
+ }
87
+ };
88
+ }
@@ -0,0 +1,107 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ import { type VerdictCache } from "../verify/cache.js";
4
+ import type { BundleVerifier } from "../verify/types.js";
5
+ import type { AcceptedDedup } from "./accepted-dedup.js";
6
+ import type { RootRecord, VoteMessage } from "./messages.js";
7
+ /**
8
+ * The forward-gate: the async gossipsub topic validator's decision core, written as a pure
9
+ * function over injected seams (no libp2p import) so it is fully unit-testable. It runs the
10
+ * FULL validity pipeline on the bundle INLINED in a received message BEFORE the message is
11
+ * re-forwarded, so an invalid bundle never crosses an honest hop and gossipsub's `reject`
12
+ * scores the sender for semantic — not just byte-level — badness. See DESIGN.md "Transport".
13
+ *
14
+ * The payload is a two-kind discriminated union (see transport/messages.ts):
15
+ * - a **bundle delta** — the wallet's own bundle as exact block bytes, validated straight
16
+ * from the message (no fetch toward the publisher exists on this path);
17
+ * - a **root record** — the checkpoint heartbeat, a fixed-shape unverifiable *hint* that
18
+ * short-circuits at layer 1: forwarded within its own per-peer rate and handed to the
19
+ * chase logic, never verified here (only equality with our own root is checkable) and
20
+ * never trusted.
21
+ *
22
+ * Verdicts (who gets blamed):
23
+ * - "accept": the inlined bundle verified (or is a known-valid re-publish), or a
24
+ * well-formed within-rate root record — deliver, merge (bundles), forward.
25
+ * - "reject": something is PROVABLY invalid (malformed message, over the derived size cap,
26
+ * a bundle that fails verification) — drop, do not forward, penalize the sender.
27
+ * - "ignore": no verdict reachable through no provable fault of the sender — either a
28
+ * transient/local condition (over the per-peer rate, internal error, deadline) or a
29
+ * view-/clock-dependent verdict two honest peers can disagree on right now: a bundle
30
+ * bucketed ahead of our chain head (`isEvaluableNow`), or a `community.name` resolved at
31
+ * head during a re-point window. Drop, do not forward, do NOT penalize, and do NOT cache
32
+ * (it can change) — using `reject` here would punish honest relayers for transient
33
+ * conditions and hand attackers a grief vector.
34
+ *
35
+ * Cheap-to-expensive with early exit: size/decode/rate first, then hash + caches, then the
36
+ * verify pipeline — so a re-published known bundle costs one hash and zero chain work.
37
+ */
38
+ export type MessageVerdict = "accept" | "reject" | "ignore";
39
+ export interface GossipGateDeps {
40
+ /** Decode a payload to its message kind; throws on malformed (see transport/messages.ts). */
41
+ decodeMessage: (data: Uint8Array) => VoteMessage;
42
+ /**
43
+ * Decode inlined bundle-block bytes and hash them to the bundle CID (the verdict-cache
44
+ * key). Throws on malformed block bytes — provable layer-1 badness.
45
+ */
46
+ parseBundle: (blockBytes: Uint8Array) => Promise<{
47
+ cid: CID;
48
+ bundle: VotesBundle;
49
+ }>;
50
+ /** The full validity pipeline for one bundle (see verify/bundle.ts). */
51
+ verifier: BundleVerifier;
52
+ /**
53
+ * Clock-aware freshness guard, kept OUT of the pure (cacheable) verifier: is this bundle's
54
+ * bucket sample block already reachable from our chain head? A bundle dated to a future
55
+ * bucket (the voter's head ahead of ours, clock skew, or an absurd `blockNumber`) is not
56
+ * yet evaluable — transient, so the gate `ignore`s it (no penalty, uncached) until our head
57
+ * catches up. Omitted ⇒ no freshness check. Steady-state votes resolve `true` with no chain
58
+ * read; only a look-ahead bundle costs a (memoized) head read.
59
+ */
60
+ isEvaluableNow?: (bundle: VotesBundle) => Promise<boolean>;
61
+ /** Per-CID verdict cache — dedups re-published bundles and known-bad blocks. */
62
+ cache: VerdictCache;
63
+ /**
64
+ * Optional dedup of already-accepted votes by `(wallet, bucket, votes)`. A same-bucket,
65
+ * same-choice re-sign under fresh bytes is dropped — not verified, not merged, not
66
+ * forwarded — so a re-sign flood costs no gate read and does not amplify. Omitted ⇒ every
67
+ * fresh bundle is verified on its own.
68
+ */
69
+ acceptedDedup?: AcceptedDedup;
70
+ /**
71
+ * Store the verified bundle's exact block bytes and admit its CID into the CRDT
72
+ * (idempotent). Exact bytes preserve byte-identity with the sender's block.
73
+ */
74
+ admit: (args: {
75
+ cid: CID;
76
+ bytes: Uint8Array;
77
+ bundle: VotesBundle;
78
+ }) => Promise<void>;
79
+ /**
80
+ * Concurrency limiter (p-limit) shared across in-flight verifications — the gate chain
81
+ * read and name resolution are network calls, so concurrent RPC work stays bounded.
82
+ */
83
+ limit: <T>(fn: () => Promise<T>) => Promise<T>;
84
+ /** Per-peer rate gate for bundle-kind messages; `false` means over-rate this window. */
85
+ allowBundlePeer: (peer: string) => boolean;
86
+ /** Per-peer rate gate for root-kind messages (heartbeats are ~1 per 10 min when honest). */
87
+ allowRootPeer: (peer: string) => boolean;
88
+ /** Called after a fresh bundle is verified and merged (drives tally-update notifications). */
89
+ onAccept?: (cid: CID, bundle: VotesBundle, from: string) => void;
90
+ /**
91
+ * Called with every well-formed, within-rate root record (an unverifiable hint). NOT
92
+ * awaited — acting on a hint (the directed-bitswap chase) is lazy and bounded elsewhere,
93
+ * never on the validator's critical path.
94
+ */
95
+ onRootRecord?: (record: RootRecord, from: string) => void;
96
+ /** The criteria-derived cap for a bundle-kind message (see messages.ts). Over ⇒ reject. */
97
+ maxBundleMessageBytes: number;
98
+ /** The fixed cap for a root-kind message (~100 B record). Over ⇒ reject. */
99
+ maxRootMessageBytes: number;
100
+ /** Hard per-message validation deadline; on expiry the verdict is `ignore`. */
101
+ timeoutMs: number;
102
+ }
103
+ export interface GossipGate {
104
+ /** Validate one received message; the returned verdict maps to gossipsub accept/reject/ignore. */
105
+ validate(data: Uint8Array, from: string): Promise<MessageVerdict>;
106
+ }
107
+ export declare function makeGossipGate(deps: GossipGateDeps): GossipGate;
@@ -0,0 +1,99 @@
1
+ import { isCacheableVerdict } from "../verify/cache.js";
2
+ /** Resolve `p`, or `onTimeout` if it does not settle within `ms`; a rejection also yields `onTimeout`. */
3
+ function withDeadline(p, ms, onTimeout) {
4
+ return new Promise((resolve) => {
5
+ let settled = false;
6
+ const timer = setTimeout(() => {
7
+ if (!settled) {
8
+ settled = true;
9
+ resolve(onTimeout);
10
+ }
11
+ }, ms);
12
+ p.then((v) => {
13
+ if (!settled) {
14
+ settled = true;
15
+ clearTimeout(timer);
16
+ resolve(v);
17
+ }
18
+ }, () => {
19
+ if (!settled) {
20
+ settled = true;
21
+ clearTimeout(timer);
22
+ resolve(onTimeout);
23
+ }
24
+ });
25
+ });
26
+ }
27
+ export function makeGossipGate(deps) {
28
+ const { decodeMessage, parseBundle, verifier, isEvaluableNow, cache, acceptedDedup, admit, limit, allowBundlePeer, allowRootPeer, onAccept, onRootRecord, maxBundleMessageBytes, maxRootMessageBytes, timeoutMs } = deps;
29
+ async function doValidate(data, from) {
30
+ // Layer 1: size and decode — the cheapest, pre-verify checks.
31
+ if (data.length > Math.max(maxBundleMessageBytes, maxRootMessageBytes))
32
+ return "reject";
33
+ let message;
34
+ try {
35
+ message = decodeMessage(data);
36
+ }
37
+ catch {
38
+ return "reject"; // malformed bytes / not one of the two kinds — provable layer-1 badness
39
+ }
40
+ if (message.kind === "root") {
41
+ if (data.length > maxRootMessageBytes)
42
+ return "reject";
43
+ if (!allowRootPeer(from))
44
+ return "ignore";
45
+ // An unverifiable hint: forward within rate, hand to the (lazy, bounded) chase.
46
+ // Never `reject`ed on content — only equality with our own root is checkable.
47
+ onRootRecord?.(message.record, from);
48
+ return "accept";
49
+ }
50
+ if (data.length > maxBundleMessageBytes)
51
+ return "reject";
52
+ if (!allowBundlePeer(from))
53
+ return "ignore";
54
+ let cid;
55
+ let bundle;
56
+ try {
57
+ ({ cid, bundle } = await parseBundle(message.bundle));
58
+ }
59
+ catch {
60
+ return "reject"; // malformed bundle block bytes — provable
61
+ }
62
+ // A known bundle short-circuits on the verdict cache for the cost of one hash: a
63
+ // re-published valid bundle (e.g. a client refreshing its vote or re-announcing a
64
+ // withdrawal) is forwarded so late peers converge, without re-verifying or re-merging;
65
+ // a known-bad one rejects without work.
66
+ const cached = cache.get(cid);
67
+ if (cached)
68
+ return cached.valid ? "accept" : cached.disposition;
69
+ // Freshness guard (transient, so it runs here — not in the cacheable verifier — and
70
+ // its verdict is never cached): a bundle bucketed ahead of our head is not yet
71
+ // evaluable, so `ignore` it without penalty until our head advances. Also defuses an
72
+ // absurd-future `blockNumber` (e.g. uint256.max) — it never merges into the set.
73
+ if (isEvaluableNow && !(await isEvaluableNow(bundle)))
74
+ return "ignore";
75
+ // Re-sign flood guard: a same-bucket, same-choice re-sign of a vote we already accepted
76
+ // is inert under LWW, so drop it WITHOUT verifying (no gate read), merging, or
77
+ // forwarding — the anti-amplification win. Safe on the untrusted `address` because a
78
+ // hit only ever suppresses something an honest peer already forwarded.
79
+ if (acceptedDedup?.isResignDuplicate(bundle))
80
+ return "ignore";
81
+ const verdict = await limit(() => verifier.verify(bundle));
82
+ if (isCacheableVerdict(verdict))
83
+ cache.set(cid, verdict); // only terminal verdicts (accept / provable reject)
84
+ if (!verdict.valid)
85
+ return verdict.disposition;
86
+ await admit({ cid, bytes: message.bundle, bundle });
87
+ acceptedDedup?.record(bundle);
88
+ onAccept?.(cid, bundle, from);
89
+ return "accept";
90
+ }
91
+ return {
92
+ validate(data, from) {
93
+ // Whole-message deadline: an internal hang or slow RPC/name resolution yields
94
+ // `ignore`, never a stuck validator (which would strand the message in gossipsub's
95
+ // mcache).
96
+ return withDeadline(doValidate(data, from), timeoutMs, "ignore");
97
+ }
98
+ };
99
+ }
@@ -0,0 +1,41 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { BlockstoreLike, FetchServiceLike, HeliaInstance, PubsubService } from "./types.js";
3
+ /**
4
+ * The host's raw blockstore surface. `get` may return EITHER a `Promise<Uint8Array>` (a plain
5
+ * `interface-blockstore`) OR an async generator yielding the block's bytes — Helia's real
6
+ * `BlockStorage` implements the streaming `Blocks` interface and yields the block (one chunk in
7
+ * practice), not a bare promise. The library works against the simpler {@link BlockstoreLike}
8
+ * contract, so {@link adaptBlockstore} normalises either shape at this one boundary.
9
+ */
10
+ interface RawBlockstore {
11
+ get(cid: CID, options?: {
12
+ signal?: AbortSignal;
13
+ }): AsyncIterable<Uint8Array> | Promise<Uint8Array>;
14
+ put(cid: CID, block: Uint8Array, options?: {
15
+ signal?: AbortSignal;
16
+ }): Promise<CID>;
17
+ has(cid: CID, options?: {
18
+ signal?: AbortSignal;
19
+ }): Promise<boolean>;
20
+ }
21
+ /**
22
+ * Adapt a raw blockstore to the library's {@link BlockstoreLike} contract: normalise `get` to a
23
+ * single `Uint8Array`. Helia's `BlockStorage.get` yields the block over an async generator (a
24
+ * single chunk for the sub-1 MiB blocks this library stores; chunks are concatenated defensively),
25
+ * while a plain blockstore returns a promise — both are handled. `put`/`has` already return
26
+ * promises, so they pass through. Exported so the transport (and the integration harness) adapt the
27
+ * injected node the same way.
28
+ */
29
+ export declare function adaptBlockstore(raw: RawBlockstore): BlockstoreLike;
30
+ /**
31
+ * Resolve and validate the gossipsub service, blockstore, and fetch service on an injected
32
+ * Helia node, throwing {@link MissingPubsubError} / {@link MissingBlockstoreError} /
33
+ * {@link MissingFetchError} if any is absent or malformed. Returns the narrowed handles so
34
+ * callers do not re-check.
35
+ */
36
+ export declare function requireHeliaServices(helia: HeliaInstance): {
37
+ pubsub: PubsubService;
38
+ blockstore: BlockstoreLike;
39
+ fetch: FetchServiceLike;
40
+ };
41
+ export {};
@@ -0,0 +1,98 @@
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
+ return {
48
+ async get(cid, options) {
49
+ const result = raw.get(cid, options);
50
+ if (!isAsyncIterable(result))
51
+ return result; // a plain Promise<Uint8Array> blockstore
52
+ const chunks = [];
53
+ for await (const chunk of result)
54
+ chunks.push(chunk);
55
+ if (chunks.length === 1)
56
+ return chunks[0];
57
+ if (chunks.length === 0)
58
+ throw new Error(`block ${cid.toString()} yielded no bytes`);
59
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
60
+ const out = new Uint8Array(total);
61
+ let offset = 0;
62
+ for (const chunk of chunks) {
63
+ out.set(chunk, offset);
64
+ offset += chunk.length;
65
+ }
66
+ return out;
67
+ },
68
+ put: (cid, block) => raw.put(cid, block),
69
+ has: (cid) => raw.has(cid)
70
+ };
71
+ }
72
+ /** Does `value` look like a libp2p fetch service (the request + responder registration)? */
73
+ function isFetchService(value) {
74
+ if (value === null || typeof value !== "object")
75
+ return false;
76
+ const candidate = value;
77
+ return (typeof candidate.fetch === "function" &&
78
+ typeof candidate.registerLookupFunction === "function" &&
79
+ typeof candidate.unregisterLookupFunction === "function");
80
+ }
81
+ /**
82
+ * Resolve and validate the gossipsub service, blockstore, and fetch service on an injected
83
+ * Helia node, throwing {@link MissingPubsubError} / {@link MissingBlockstoreError} /
84
+ * {@link MissingFetchError} if any is absent or malformed. Returns the narrowed handles so
85
+ * callers do not re-check.
86
+ */
87
+ export function requireHeliaServices(helia) {
88
+ const pubsub = helia.libp2p?.services?.pubsub;
89
+ if (!isPubsubService(pubsub))
90
+ throw new MissingPubsubError();
91
+ const blockstore = helia.blockstore;
92
+ if (!isRawBlockstore(blockstore))
93
+ throw new MissingBlockstoreError();
94
+ const fetch = helia.libp2p?.services?.fetch;
95
+ if (!isFetchService(fetch))
96
+ throw new MissingFetchError();
97
+ return { pubsub, blockstore: adaptBlockstore(blockstore), fetch };
98
+ }