@bitsocial/pubsub-voting 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +223 -0
  3. package/dist/chain/bucket.d.ts +13 -0
  4. package/dist/chain/bucket.js +24 -0
  5. package/dist/chain/coalescer.d.ts +56 -0
  6. package/dist/chain/coalescer.js +217 -0
  7. package/dist/chain/ticker.d.ts +15 -0
  8. package/dist/chain/ticker.js +25 -0
  9. package/dist/chain/types.d.ts +90 -0
  10. package/dist/chain/types.js +1 -0
  11. package/dist/checkpoint/codec.d.ts +54 -0
  12. package/dist/checkpoint/codec.js +99 -0
  13. package/dist/client/root-puller.d.ts +49 -0
  14. package/dist/client/root-puller.js +140 -0
  15. package/dist/client/voter.d.ts +239 -0
  16. package/dist/client/voter.js +1343 -0
  17. package/dist/crdt/codec.d.ts +41 -0
  18. package/dist/crdt/codec.js +137 -0
  19. package/dist/crdt/crdt.d.ts +22 -0
  20. package/dist/crdt/crdt.js +127 -0
  21. package/dist/crdt/store.d.ts +8 -0
  22. package/dist/crdt/store.js +23 -0
  23. package/dist/crdt/types.d.ts +87 -0
  24. package/dist/crdt/types.js +1 -0
  25. package/dist/encoding/canonical.d.ts +22 -0
  26. package/dist/encoding/canonical.js +26 -0
  27. package/dist/errors.d.ts +81 -0
  28. package/dist/errors.js +126 -0
  29. package/dist/index.d.ts +32 -0
  30. package/dist/index.js +43 -0
  31. package/dist/rules/constant.d.ts +14 -0
  32. package/dist/rules/constant.js +18 -0
  33. package/dist/rules/erc20-balance.d.ts +30 -0
  34. package/dist/rules/erc20-balance.js +44 -0
  35. package/dist/rules/erc721-min-balance.d.ts +18 -0
  36. package/dist/rules/erc721-min-balance.js +100 -0
  37. package/dist/rules/registry.d.ts +42 -0
  38. package/dist/rules/registry.js +61 -0
  39. package/dist/rules/types.d.ts +74 -0
  40. package/dist/rules/types.js +1 -0
  41. package/dist/schema/common.d.ts +25 -0
  42. package/dist/schema/common.js +24 -0
  43. package/dist/schema/criteria.d.ts +78 -0
  44. package/dist/schema/criteria.js +76 -0
  45. package/dist/schema/directory.d.ts +42 -0
  46. package/dist/schema/directory.js +52 -0
  47. package/dist/schema/votes.d.ts +55 -0
  48. package/dist/schema/votes.js +116 -0
  49. package/dist/signer/eip712.d.ts +103 -0
  50. package/dist/signer/eip712.js +85 -0
  51. package/dist/signer/types.d.ts +31 -0
  52. package/dist/signer/types.js +1 -0
  53. package/dist/storage/browser.d.ts +3 -0
  54. package/dist/storage/browser.js +110 -0
  55. package/dist/storage/memory.d.ts +10 -0
  56. package/dist/storage/memory.js +56 -0
  57. package/dist/storage/node.d.ts +5 -0
  58. package/dist/storage/node.js +106 -0
  59. package/dist/storage/types.d.ts +46 -0
  60. package/dist/storage/types.js +1 -0
  61. package/dist/store/indexeddb.d.ts +9 -0
  62. package/dist/store/indexeddb.js +72 -0
  63. package/dist/store/memory.d.ts +15 -0
  64. package/dist/store/memory.js +22 -0
  65. package/dist/store/select.d.ts +15 -0
  66. package/dist/store/select.js +64 -0
  67. package/dist/store/sqlite.d.ts +11 -0
  68. package/dist/store/sqlite.js +68 -0
  69. package/dist/store/types.d.ts +57 -0
  70. package/dist/store/types.js +1 -0
  71. package/dist/tally/tally.d.ts +44 -0
  72. package/dist/tally/tally.js +89 -0
  73. package/dist/tally/types.d.ts +51 -0
  74. package/dist/tally/types.js +13 -0
  75. package/dist/topic.d.ts +20 -0
  76. package/dist/topic.js +28 -0
  77. package/dist/transport/accepted-dedup.d.ts +30 -0
  78. package/dist/transport/accepted-dedup.js +34 -0
  79. package/dist/transport/announce/browser.d.ts +9 -0
  80. package/dist/transport/announce/browser.js +14 -0
  81. package/dist/transport/announce/node.d.ts +38 -0
  82. package/dist/transport/announce/node.js +162 -0
  83. package/dist/transport/announce/types.d.ts +74 -0
  84. package/dist/transport/announce/types.js +16 -0
  85. package/dist/transport/bundle-store.d.ts +11 -0
  86. package/dist/transport/bundle-store.js +34 -0
  87. package/dist/transport/chase.d.ts +125 -0
  88. package/dist/transport/chase.js +182 -0
  89. package/dist/transport/gossip-validator.d.ts +107 -0
  90. package/dist/transport/gossip-validator.js +99 -0
  91. package/dist/transport/helia.d.ts +55 -0
  92. package/dist/transport/helia.js +114 -0
  93. package/dist/transport/integration/harness.d.ts +65 -0
  94. package/dist/transport/integration/harness.js +278 -0
  95. package/dist/transport/messages.d.ts +97 -0
  96. package/dist/transport/messages.js +117 -0
  97. package/dist/transport/rate-limit.d.ts +11 -0
  98. package/dist/transport/rate-limit.js +20 -0
  99. package/dist/transport/transport.d.ts +20 -0
  100. package/dist/transport/transport.js +35 -0
  101. package/dist/transport/types.d.ts +189 -0
  102. package/dist/transport/types.js +1 -0
  103. package/dist/verify/background.d.ts +81 -0
  104. package/dist/verify/background.js +236 -0
  105. package/dist/verify/bundle.d.ts +58 -0
  106. package/dist/verify/bundle.js +84 -0
  107. package/dist/verify/cache.d.ts +48 -0
  108. package/dist/verify/cache.js +62 -0
  109. package/dist/verify/constraints.d.ts +16 -0
  110. package/dist/verify/constraints.js +35 -0
  111. package/dist/verify/gate-result-cache.d.ts +65 -0
  112. package/dist/verify/gate-result-cache.js +91 -0
  113. package/dist/verify/name-resolution-cache.d.ts +59 -0
  114. package/dist/verify/name-resolution-cache.js +64 -0
  115. package/dist/verify/signature.d.ts +9 -0
  116. package/dist/verify/signature.js +55 -0
  117. package/dist/verify/types.d.ts +101 -0
  118. package/dist/verify/types.js +1 -0
  119. package/package.json +78 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,189 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { PeerId } from "@libp2p/interface";
3
+ import type { Helia } from "helia";
4
+ import type { RootRecord } from "./messages.js";
5
+ /**
6
+ * Transport interfaces. This is the ONLY part of the library that
7
+ * touches libp2p/helia. The core (schema/verify/crdt/tally) does not import it, so the
8
+ * engine is testable without a network.
9
+ *
10
+ * Three exchanges (see DESIGN.md "Transport"):
11
+ * - pubsub: broadcast and receive **inline bundle deltas** and **root-record heartbeats**
12
+ * (gossipsub), with a topic validator that drops invalid messages before the mesh
13
+ * re-forwards them.
14
+ * - fetch protocol: pull a connected peer's current root record on cold start / reconnect,
15
+ * then union across peers so a single liar cannot hide a vote.
16
+ * - directed bitswap: pull the checkpoint blocks behind an advertised root from the
17
+ * connected peers that advertised it (through the blockstore) — the only bitswap use.
18
+ *
19
+ * The library does not start a node and does not take a host SDK. It receives the
20
+ * host's already-running **Helia node** directly (e.g. the value `createHelia` returns,
21
+ * which for a pkc-js host is `pkc.clients.libp2pJsClients[key]._helia`) and drives its
22
+ * libp2p pubsub + blockstore itself — there is no host-written adapter. The node must
23
+ * carry a gossipsub service at `libp2p.services.pubsub` and a usable `blockstore`;
24
+ * construction enforces both (see `requireHeliaServices` / `MissingPubsubError` /
25
+ * `MissingBlockstoreError`).
26
+ */
27
+ /**
28
+ * Verdict from a gossipsub topic validator. Mirrors the semantics of libp2p's
29
+ * `TopicValidatorResult` (accept / reject / ignore) but is declared locally:
30
+ * `@libp2p/interface` 3.x no longer re-exports the pubsub types, so the
31
+ * implementation maps these onto whatever enum the host's gossipsub uses.
32
+ * - "accept": valid; deliver to subscribers and let the mesh re-forward it.
33
+ * - "reject": invalid; drop, do not forward, and penalize the sender's peer score.
34
+ * - "ignore": drop and do not forward, without penalizing (well-formed but not useful).
35
+ */
36
+ export type TopicValidatorResult = "accept" | "reject" | "ignore";
37
+ /**
38
+ * The subset of a libp2p pubsub (gossipsub) service this library drives, declared
39
+ * structurally because `@libp2p/interface` 3.x no longer re-exports `PubSub`. Any
40
+ * gossipsub implementation the host registers at `services.pubsub` satisfies this
41
+ * (e.g. `@chainsafe/libp2p-gossipsub`). Kept minimal on purpose; the full transport
42
+ * (topic validators, fetch protocol) is design-only and may widen this as it lands.
43
+ */
44
+ export interface PubsubService {
45
+ /**
46
+ * Broadcast bytes to a topic mesh. gossipsub resolves to `{ recipients }` — the peers it
47
+ * *directly sent the RPC to* at publish time (first-hop fan-out, filtered for send failures),
48
+ * NOT total network reach and NOT an acceptance confirmation (each recipient still runs the
49
+ * forward-gate). With the host's default `floodPublish`, that's every connected topic peer
50
+ * above the publish score threshold. Typed loosely (`recipients` optional) so a non-gossipsub
51
+ * pubsub still satisfies it; the transport reads `recipients?.length ?? 0`. Note gossipsub
52
+ * *rejects* with `NoPeersSubscribedToTopic` when it would send to zero peers unless the host
53
+ * sets `allowPublishToZeroTopicPeers`.
54
+ */
55
+ publish(topic: string, data: Uint8Array): Promise<{
56
+ recipients?: readonly PeerId[];
57
+ }>;
58
+ /** Join a topic; received messages arrive via the "message" event. */
59
+ subscribe(topic: string): void;
60
+ /** Leave a topic. */
61
+ unsubscribe(topic: string): void;
62
+ /** Peers we currently see subscribed to a topic, used to pick fetch targets. */
63
+ getSubscribers(topic: string): PeerId[];
64
+ addEventListener(type: "message", listener: (evt: {
65
+ detail: {
66
+ topic: string;
67
+ data: Uint8Array;
68
+ from?: PeerId;
69
+ };
70
+ }) => void): void;
71
+ removeEventListener(type: "message", listener: (evt: {
72
+ detail: {
73
+ topic: string;
74
+ data: Uint8Array;
75
+ from?: PeerId;
76
+ };
77
+ }) => void): void;
78
+ /**
79
+ * gossipsub's per-topic validator map. The transport installs the async forward-gate
80
+ * here (`topicValidators.set(topic, gate)`); gossipsub awaits the returned promise
81
+ * before re-forwarding the message to the mesh, which is what makes `reject` land on the
82
+ * sender for semantic invalidity. Optional because a non-gossipsub pubsub lacks it — the
83
+ * transport checks for it at `start()` and throws if absent. `@libp2p/gossipsub` exposes
84
+ * it as a mutable `Map`. See DESIGN.md "Transport".
85
+ */
86
+ topicValidators?: Map<string, GossipTopicValidator>;
87
+ }
88
+ /** A received pubsub message, as passed to a gossipsub topic validator. */
89
+ export interface GossipMessage {
90
+ topic: string;
91
+ data: Uint8Array;
92
+ from?: PeerId;
93
+ }
94
+ /**
95
+ * A gossipsub topic validator: run on every received message BEFORE re-forwarding, returning
96
+ * (or resolving to) a {@link TopicValidatorResult}. Returning a promise makes the gate async —
97
+ * gossipsub awaits it, so the full fetch + verify pipeline can run before the message is
98
+ * forwarded (see DESIGN.md "Transport"). Declared structurally so the library imports no
99
+ * gossipsub package; `@libp2p/gossipsub`'s `TopicValidatorFn` satisfies it.
100
+ */
101
+ export type GossipTopicValidator = (peer: PeerId, message: GossipMessage) => TopicValidatorResult | Promise<TopicValidatorResult>;
102
+ /**
103
+ * The subset of the libp2p **fetch service** (`@libp2p/fetch`) this library drives,
104
+ * declared structurally so no runtime dependency on the package is needed. The host MUST
105
+ * register it at `libp2p.services.fetch`; construction throws `MissingFetchError`
106
+ * otherwise. Used for the root-record pull (see DESIGN.md "Checkpoints"): this library
107
+ * registers a lookup for its own key prefix (the responder) and fetches connected topic
108
+ * peers' records on cold start (the requester).
109
+ */
110
+ export interface FetchServiceLike {
111
+ /** Request the value for `key` from a connected peer; nullish when the peer has none. */
112
+ fetch(peer: PeerId, key: string, options?: {
113
+ signal?: AbortSignal;
114
+ }): Promise<Uint8Array | undefined | null>;
115
+ /**
116
+ * Register the responder for every key starting with `prefix`. `@libp2p/fetch` invokes the
117
+ * lookup with the requested key as **raw bytes** (`Uint8Array`), not a string — it matches the
118
+ * `prefix` against the utf8-decoded key but hands the callback the identifier bytes. The
119
+ * responder decodes them itself (see `PubsubVoter.#rootLookup`).
120
+ */
121
+ registerLookupFunction(prefix: string, lookup: (key: Uint8Array) => Promise<Uint8Array | undefined>): void;
122
+ /** Remove a registered responder (all of the prefix's, when `lookup` is omitted). */
123
+ unregisterLookupFunction(prefix: string, lookup?: (key: Uint8Array) => Promise<Uint8Array | undefined>): void;
124
+ }
125
+ /**
126
+ * The subset of a Helia blockstore this library drives, declared structurally. Vote
127
+ * bundles are content-addressed blocks fetched/stored by CID through it (bitswap
128
+ * retrieves through the blockstore). The full `Blocks` type carries progress-event
129
+ * generics we do not need here.
130
+ */
131
+ export interface BlockstoreLike {
132
+ /** `options.signal` cancels an in-flight bitswap fetch — see the chase deadline (DESIGN.md "Checkpoints"). */
133
+ get(cid: CID, options?: {
134
+ signal?: AbortSignal;
135
+ }): Promise<Uint8Array>;
136
+ put(cid: CID, block: Uint8Array): Promise<CID>;
137
+ has(cid: CID): Promise<boolean>;
138
+ /**
139
+ * Open a provider-scoped bitswap session rooted at `root`: wants go to the session's
140
+ * providers as targeted session wants instead of a broadcast to every connected peer, and
141
+ * provider discovery runs once per session instead of once per block (see DESIGN.md "Block
142
+ * pull"). Optional — plain blockstores (and the unit tests' mocks) lack it; callers MUST
143
+ * feature-detect and fall back to the broadcast `get`.
144
+ */
145
+ createSession?(root: CID, options: {
146
+ providers: PeerId[];
147
+ maxProviders?: number;
148
+ }): BlockSessionLike;
149
+ }
150
+ /**
151
+ * A session-scoped view of {@link BlockstoreLike.get}, mirroring Helia's `SessionBlockstore`.
152
+ * Blocks fetched through it still land in the underlying blockstore.
153
+ */
154
+ export interface BlockSessionLike {
155
+ get(cid: CID, options?: {
156
+ signal?: AbortSignal;
157
+ }): Promise<Uint8Array>;
158
+ /** Add a late-arriving provider to the running session (rejections are the caller's to swallow). */
159
+ addPeer(peer: PeerId): Promise<void> | void;
160
+ /** Abort the session's in-flight wants and release it. */
161
+ close(): void;
162
+ }
163
+ /**
164
+ * The host's running Helia node, as injected into {@link PubsubVoter}. Typed with the
165
+ * default libp2p `ServiceMap`, so `libp2p.services.pubsub` is `unknown` and cannot be
166
+ * trusted at compile time (a plain Helia node has no pubsub) — `requireHeliaServices`
167
+ * validates the gossipsub service and the blockstore at construction and narrows them.
168
+ */
169
+ export type HeliaInstance = Helia;
170
+ /** Live-delta propagation over pubsub (one inline bundle per message + root heartbeats). */
171
+ export interface VoteTransport {
172
+ /**
173
+ * Subscribe to the topic and install the async validate-before-forward gate as the
174
+ * topic validator. See DESIGN.md "Transport: gossipsub topic + validation".
175
+ */
176
+ start(): Promise<void>;
177
+ stop(): Promise<void>;
178
+ /**
179
+ * Publish one bundle's exact binary block bytes as a live delta (a new vote, a client
180
+ * re-publish, or a withdrawal). Resolves `recipientCount`: how many peers gossipsub sent the
181
+ * message directly to (see {@link PubsubService.publish} for what that number does and does not
182
+ * mean).
183
+ */
184
+ publishBundle(blockBytes: Uint8Array): Promise<{
185
+ recipientCount: number;
186
+ }>;
187
+ /** Publish a root-record heartbeat (see DESIGN.md "Checkpoints"). */
188
+ publishRootRecord(record: RootRecord): Promise<void>;
189
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import type { CID } from "multiformats/cid";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ import type { Criteria } from "../schema/criteria.js";
4
+ import type { RuleRegistry } from "../rules/types.js";
5
+ import type { ChainClient, BucketMath, NameResolver } from "../chain/types.js";
6
+ import type { GateResultCache } from "./gate-result-cache.js";
7
+ import { type NameResolutionCache } from "./name-resolution-cache.js";
8
+ import type { VerdictCache } from "./cache.js";
9
+ import type { VerifyFail } from "./types.js";
10
+ /**
11
+ * The background chain verifier: runs the two deferred NETWORK checks — the on-chain gate
12
+ * (`rule` scores the wallet `> 0n` at the bucket block) and community-name resolution — for
13
+ * bundles that were admitted *provisionally* after the synchronous offline checks (signature +
14
+ * constraints). This is what makes a cold join non-blocking: the chase admits a checkpoint's
15
+ * bundles on offline validity alone (µs each), the first tally renders immediately with
16
+ * `chainVerified: false` rows, and this verifier confirms or evicts in the background (see
17
+ * DESIGN.md "Background chain verification").
18
+ *
19
+ * Batched, not sequential: a checkpoint's bundles share one bucket sample block, so the gate
20
+ * stage groups pending wallets per sample block and prefers the rule's `evaluateMany` (one
21
+ * multicall3 round trip for N wallets) over N serial `readContract` calls, falling back to
22
+ * `limit`-bounded per-wallet reads for rules without a batched form. Results feed the shared
23
+ * `(wallet, sampleBlock)` gate-result cache, and a settled bundle's terminal verdict feeds the
24
+ * shared per-CID verdict cache — so a later re-publish of the same bundle short-circuits at
25
+ * the gossip gate with zero chain work.
26
+ *
27
+ * Failure classes are kept apart, mirroring the forward-gate's `reject`/`ignore` split:
28
+ * - gate scores `0n` → EVICT + cache the provable `reject` (deterministic).
29
+ * - name missing/mismatched → EVICT, NOT cached (view-dependent `ignore`-class: v1
30
+ * resolves at head — see verify/bundle.ts step 4).
31
+ * - RPC / resolver THREW → infra, nobody's verdict: the bundle STAYS pending, the
32
+ * round retries with capped full-jitter backoff, and
33
+ * `onError` surfaces the degraded state to the host
34
+ * (Contest `error`) so "RPC down" is not silent.
35
+ *
36
+ * Pure seams, no libp2p import — unit-testable offline like the rest of the engine.
37
+ */
38
+ /** One provisionally admitted bundle awaiting its deferred checks. */
39
+ export interface PendingBundle {
40
+ cid: CID;
41
+ bundle: VotesBundle;
42
+ }
43
+ export interface BackgroundVerifierDeps {
44
+ criteria: Criteria;
45
+ registry: RuleRegistry;
46
+ chainFor: (ticker: string) => ChainClient;
47
+ bucketMath: BucketMath;
48
+ nameResolvers: NameResolver[];
49
+ /** Shared `(wallet, sampleBlock)` gate scores — batch results land here, hits skip the read. */
50
+ gateResultCache: GateResultCache;
51
+ /** Shared persistent name-resolution cache (pkc-js rule, 1h max-age); omitted ⇒ resolve live. */
52
+ nameResolutionCache?: NameResolutionCache;
53
+ /** The gate's per-CID verdict cache — a settled bundle's terminal verdict is stored here. */
54
+ cache: VerdictCache;
55
+ /** The bundle's gate read confirmed `> 0n` (flip `chainVerified`, kick the tally). */
56
+ onGateVerified: (cid: CID) => void;
57
+ /** The bundle's carried name resolved to its claimed publicKey (flip `nameResolved`). */
58
+ onNameResolved: (cid: CID) => void;
59
+ /** Remove a failed bundle from the working set (gate `0n`, or a name that did not check out). */
60
+ onEvict: (cid: CID, verdict: VerifyFail) => void;
61
+ /** An infra-class failure (RPC/resolver threw): the round will retry; surface the degradation. */
62
+ onError: (error: unknown) => void;
63
+ /** Concurrency cap for the un-batched fallbacks (per-wallet reads, name resolutions). */
64
+ limit: <T>(fn: () => Promise<T>) => Promise<T>;
65
+ /** Infra-retry backoff base / cap (ms). Full-jittered exponential between rounds. */
66
+ retryBaseMs?: number;
67
+ retryCapMs?: number;
68
+ }
69
+ export interface BackgroundChainVerifier {
70
+ /** Queue provisionally admitted bundles and return immediately; the drain runs detached. */
71
+ enqueue(entries: PendingBundle[]): void;
72
+ /** Bundles whose deferred checks have not settled yet (queued, in-flight, or awaiting retry). */
73
+ pendingCount(): number;
74
+ /** Resolves once every queued bundle has settled and no retry is armed (tests/introspection). */
75
+ idle(): Promise<void>;
76
+ /** Clear the retry timer (topic leave / voter destroy). Pending state is kept for `resume`. */
77
+ stop(): void;
78
+ /** Re-kick the drain after `stop()` if anything is still pending (topic re-join). */
79
+ resume(): void;
80
+ }
81
+ export declare function makeBackgroundVerifier(deps: BackgroundVerifierDeps): BackgroundChainVerifier;