@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,90 @@
1
+ import type { PublicClient } from "viem";
2
+ import type { ChainConfig } from "../schema/criteria.js";
3
+ /**
4
+ * Chain access.
5
+ *
6
+ * A `ChainClient` is just a viem `PublicClient`. The library does not wrap it in a
7
+ * curated read API (no `balanceOfErc20`/`balanceOfErc721` helpers): every rule
8
+ * writes its own reads with the full viem surface (`readContract`, `getBalance`,
9
+ * `call`, multicall, ...) against whatever ABI it needs. That keeps custom
10
+ * rules unconstrained — a host can read any contract shape without waiting for
11
+ * a helper to be added here.
12
+ *
13
+ * Reads must be pinned to an explicit historical block (the sampled block for a
14
+ * bundle's bucket) so every verifier prices the same state — rules pass
15
+ * `blockNumber: BigInt(ctx.blockNumber)` to each viem call. viem is allowed in the
16
+ * core (it carries no libp2p/helia import); only `src/transport/` touches the node.
17
+ *
18
+ * pkc-js has no equivalent: it touches chains only for name resolution and has no
19
+ * balance reads or chainTicker-to-RPC mapping. All of this is net-new here.
20
+ */
21
+ export type ChainClient = PublicClient;
22
+ /** chainTicker -> client, built from `criteria.requires.chains`. */
23
+ export type ChainClients = Record<string, ChainClient>;
24
+ /**
25
+ * Factory the host provides: turn a chain config into a viem `PublicClient`
26
+ * (typically `createPublicClient({ transport: http(config.rpcUrls[0]) })`).
27
+ * Declared here so the public API can describe how chain clients are supplied.
28
+ */
29
+ export type ChainClientFactory = (args: {
30
+ chain: string;
31
+ config: ChainConfig;
32
+ }) => ChainClient;
33
+ /**
34
+ * A community-name resolver the host injects (`PubsubVoterOptions.nameResolvers`). The
35
+ * shape is structurally identical to pkc-js's `NameResolverInterface`, so a host passes
36
+ * the very same instances it already gives pkc-js (e.g. `@bitsocial/bso-resolver`'s
37
+ * `BsoResolver`, which resolves `name.bso` through the `bitsocial` text record) —
38
+ * declared here rather than imported so this library depends on no resolver package.
39
+ *
40
+ * The tally uses it to verify a vote's `community.name` claim: resolve the name and drop
41
+ * the bundle when it does not resolve or resolves to a different `publicKey` than the
42
+ * vote claims (see DESIGN.md "Tally"). `resolve` returning `undefined` means the name
43
+ * has no record. `resolve` accepts an optional `blockNumber` to pin the read to a
44
+ * canonical historical block (bso-resolver#3, since resolved upstream); when omitted it
45
+ * resolves at head. v1 still resolves at head: the registry lives on its own chain, so
46
+ * pinning also needs a canonical per-bucket block *on the registry's chain* — that
47
+ * multi-chain block-selection half is still open. See DESIGN.md "Open questions",
48
+ * "Pinned-block name resolution".
49
+ */
50
+ export interface NameResolver {
51
+ /** Identifies this resolver instance (e.g. "bso-viem"). */
52
+ key: string;
53
+ /** The backing provider label (e.g. "viem"). */
54
+ provider: string;
55
+ /** Resolve a name to its record; `undefined` when the name has no record. */
56
+ resolve: (opts: {
57
+ name: string;
58
+ /**
59
+ * Pin the text-record read to a canonical historical block; resolves at head
60
+ * when omitted. v1 leaves it unset (head) until per-bucket block selection on
61
+ * the registry's chain lands — see the interface note above.
62
+ */
63
+ blockNumber?: bigint;
64
+ abortSignal?: AbortSignal;
65
+ }) => Promise<{
66
+ publicKey: string;
67
+ [key: string]: string;
68
+ } | undefined>;
69
+ /** True when this resolver handles the name's TLD (e.g. ends with ".bso"). */
70
+ canResolve: (opts: {
71
+ name: string;
72
+ }) => boolean;
73
+ destroy?: () => Promise<void>;
74
+ }
75
+ /**
76
+ * Bucket math (documented here, implemented later).
77
+ *
78
+ * bucketForBlock(block) = Math.floor(block / blocksPerBucket)
79
+ * sampleBlockForBucket(bucket) = the canonical block at which balances are read
80
+ * for that bucket
81
+ *
82
+ * Using one sample block per bucket is what stops votes from flip-flopping mid
83
+ * bucket and what makes every verifier agree. The exact sample-block rule (bucket
84
+ * start, or a block derived from a blockhash to resist flash-loan timing) is a
85
+ * tuning decision recorded in DESIGN.md.
86
+ */
87
+ export interface BucketMath {
88
+ bucketForBlock(blockNumber: number): number;
89
+ sampleBlockForBucket(bucket: number): number;
90
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { CID } from "multiformats/cid";
2
+ import type { VotesBundle } from "../schema/votes.js";
3
+ /**
4
+ * Checkpoint codec — the compacted snapshot of a topic's current LWW winners (one bundle per
5
+ * wallet), used for fast cold-start and storage compaction (see DESIGN.md "Checkpoints"). Pure and
6
+ * network-free: it turns a winner set into content-addressed blocks and back, so it is unit-testable
7
+ * offline. Fetching/publishing those blocks over libp2p is a separate, host-blocked concern.
8
+ *
9
+ * Format — a shallow depth-2 pagination DAG (NOT a history DAG: no parent links, regenerated fresh
10
+ * each cut):
11
+ * - winners are sorted **ascending by `address`** (unique per wallet under LWW, so no tie);
12
+ * - the sorted bundles are packed, **full and inlined**, into chunk blocks by a size-cap fill:
13
+ * append bundles into a chunk until the next would push the chunk's inlined-bundle bytes over
14
+ * `maxChunkBytes`, then start a new chunk (a single oversized bundle still forms its own chunk);
15
+ * - a root block lists the chunk CIDs: `{ chunks: CID[] }`.
16
+ *
17
+ * Canonical dag-cbor + the address sort + the size-cap rule make the bytes a pure function of the
18
+ * winner set, so any two seeders with the same view produce the **same root CID** and their blocks
19
+ * dedupe. The byte layout is pinned by the fixed test vector in `codec.test.ts`.
20
+ */
21
+ /** One content-addressed checkpoint block (a chunk, or the root manifest). */
22
+ export interface CheckpointBlock {
23
+ cid: CID;
24
+ bytes: Uint8Array;
25
+ }
26
+ /** The result of a checkpoint cut: the root CID plus every block to store (chunks + root). */
27
+ export interface EncodedCheckpoint {
28
+ root: CID;
29
+ /**
30
+ * The chunk-CID index (the root manifest's contents), in root order. Exposed so the
31
+ * root record can carry it (see DESIGN.md "Checkpoints", "Block pull"): a cold joiner
32
+ * handed a verified chunk index skips the root-manifest bitswap round-trip and pulls
33
+ * every chunk in parallel. `CID(encodeCanonical({ chunks })) === root`, so it is
34
+ * self-verifying against the root and cannot be a new trust vector.
35
+ */
36
+ chunks: CID[];
37
+ blocks: CheckpointBlock[];
38
+ }
39
+ /** Default chunk ceiling (bytes of inlined bundles per chunk), just under a 1 MiB block. */
40
+ export declare const DEFAULT_MAX_CHUNK_BYTES: number;
41
+ /**
42
+ * Encode a winner set into checkpoint blocks. `winners` should be the CRDT's current (non-expired)
43
+ * LWW winners; order does not matter (they are sorted here). Returns the root CID and every block to
44
+ * persist. Deterministic: identical winners + `maxChunkBytes` ⇒ identical bytes and root CID.
45
+ */
46
+ export declare function encodeCheckpoint(winners: VotesBundle[], maxChunkBytes?: number): Promise<EncodedCheckpoint>;
47
+ /**
48
+ * Decode a checkpoint back into its inlined winner bundles, given a way to fetch each block by CID
49
+ * (blockstore/bitswap). This is a *structural* decode — each bundle is schema-validated (wire shape,
50
+ * B58 key, distinct communities) but NOT signature-verified; the caller re-verifies each through the
51
+ * same verifier the gate uses before merging (a single seeder cannot forge or hide a vote — see
52
+ * DESIGN.md "Checkpoints"). Throws if a referenced block is unavailable or malformed.
53
+ */
54
+ export declare function decodeCheckpoint(root: CID, getBlock: (cid: CID) => Promise<Uint8Array | undefined>, knownChunks?: CID[]): Promise<VotesBundle[]>;
@@ -0,0 +1,99 @@
1
+ import { CID } from "multiformats/cid";
2
+ import { sha256 } from "multiformats/hashes/sha2";
3
+ import * as dagCbor from "@ipld/dag-cbor";
4
+ import { encodeCanonical, dagCborCode } from "../encoding/canonical.js";
5
+ import { encodeBundle, toWireBundle, fromWireBundle } from "../crdt/codec.js";
6
+ /** Default chunk ceiling (bytes of inlined bundles per chunk), just under a 1 MiB block. */
7
+ export const DEFAULT_MAX_CHUNK_BYTES = 1 << 20;
8
+ async function blockFor(bytes) {
9
+ const digest = await sha256.digest(bytes);
10
+ return { cid: CID.createV1(dagCborCode, digest), bytes };
11
+ }
12
+ /**
13
+ * The root-manifest block for a chunk-CID list: `{ chunks }` canonically encoded and hashed. Its
14
+ * CID is the checkpoint root, so re-deriving it from a chunk list proves the list belongs to a
15
+ * given root — the local check that lets a joiner trust a piggybacked chunk index (see
16
+ * {@link decodeCheckpoint}) without fetching the manifest.
17
+ */
18
+ async function checkpointRootBlock(chunks) {
19
+ const root = { chunks };
20
+ return blockFor(encodeCanonical(root));
21
+ }
22
+ /**
23
+ * Encode a winner set into checkpoint blocks. `winners` should be the CRDT's current (non-expired)
24
+ * LWW winners; order does not matter (they are sorted here). Returns the root CID and every block to
25
+ * persist. Deterministic: identical winners + `maxChunkBytes` ⇒ identical bytes and root CID.
26
+ */
27
+ export async function encodeCheckpoint(winners, maxChunkBytes = DEFAULT_MAX_CHUNK_BYTES) {
28
+ // Lowercase before comparing so the sort equals raw byte order regardless of how a caller
29
+ // cased an address — casing is presentation, the wire form is lowercase bytes.
30
+ const sorted = [...winners]
31
+ .map((bundle) => ({ bundle, key: bundle.address.toLowerCase() }))
32
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
33
+ .map((entry) => entry.bundle);
34
+ // Size-cap fill on the inlined-bundle byte total (a pure function of the bundles, so seeders
35
+ // agree without encoding a partial chunk each step).
36
+ const chunks = [];
37
+ let current = [];
38
+ let currentBytes = 0;
39
+ for (const bundle of sorted) {
40
+ const size = encodeBundle(bundle).length;
41
+ if (current.length > 0 && currentBytes + size > maxChunkBytes) {
42
+ chunks.push(current);
43
+ current = [];
44
+ currentBytes = 0;
45
+ }
46
+ current.push(bundle);
47
+ currentBytes += size;
48
+ }
49
+ if (current.length > 0)
50
+ chunks.push(current);
51
+ const blocks = [];
52
+ const chunkCids = [];
53
+ for (const chunk of chunks) {
54
+ // Chunks inline the same binary wire objects the bundle block uses (see crdt/codec.ts),
55
+ // so the binary-field byte saving multiplies across every inlined winner.
56
+ const block = await blockFor(encodeCanonical(chunk.map(toWireBundle)));
57
+ blocks.push(block);
58
+ chunkCids.push(block.cid);
59
+ }
60
+ const rootBlock = await checkpointRootBlock(chunkCids);
61
+ blocks.push(rootBlock);
62
+ return { root: rootBlock.cid, chunks: chunkCids, blocks };
63
+ }
64
+ /**
65
+ * Decode a checkpoint back into its inlined winner bundles, given a way to fetch each block by CID
66
+ * (blockstore/bitswap). This is a *structural* decode — each bundle is schema-validated (wire shape,
67
+ * B58 key, distinct communities) but NOT signature-verified; the caller re-verifies each through the
68
+ * same verifier the gate uses before merging (a single seeder cannot forge or hide a vote — see
69
+ * DESIGN.md "Checkpoints"). Throws if a referenced block is unavailable or malformed.
70
+ */
71
+ export async function decodeCheckpoint(root, getBlock, knownChunks) {
72
+ // If the caller supplies a chunk index (piggybacked on the root record — see DESIGN.md
73
+ // "Block pull"), trust it only after re-deriving the manifest and checking its CID equals
74
+ // `root`: a lie fails this local check and falls back to the manifest fetch, so the index is
75
+ // an optimization, never a new trust vector. When it verifies, the root-manifest bitswap
76
+ // round-trip is skipped entirely.
77
+ let chunks;
78
+ if (knownChunks !== undefined && (await checkpointRootBlock(knownChunks)).cid.equals(root)) {
79
+ chunks = knownChunks;
80
+ }
81
+ else {
82
+ const rootBytes = await getBlock(root);
83
+ if (!rootBytes)
84
+ throw new Error(`checkpoint root block ${root.toString()} is unavailable`);
85
+ chunks = dagCbor.decode(rootBytes).chunks;
86
+ }
87
+ // The chunks are independent blocks, so pull them concurrently (one bitswap round-trip for
88
+ // the whole set, not one per chunk); decode preserves root order for a deterministic result.
89
+ const perChunk = await Promise.all(chunks.map(async (chunkCid) => {
90
+ const chunkBytes = await getBlock(chunkCid);
91
+ if (!chunkBytes)
92
+ throw new Error(`checkpoint chunk block ${chunkCid.toString()} is unavailable`);
93
+ const wires = dagCbor.decode(chunkBytes);
94
+ if (!Array.isArray(wires))
95
+ throw new Error(`checkpoint chunk ${chunkCid.toString()} is not a bundle array`);
96
+ return wires.map(fromWireBundle);
97
+ }));
98
+ return perChunk.flat();
99
+ }
@@ -0,0 +1,49 @@
1
+ import type { PeerId } from "@libp2p/interface";
2
+ import type { FetchServiceLike } from "../transport/types.js";
3
+ import { type FetchRootRecord } from "../transport/messages.js";
4
+ /**
5
+ * The cold-start root puller: the voter-wide seam every engine's cold-join pull goes through
6
+ * (see DESIGN.md "Checkpoints"). One instance per voter, because everything it guards is
7
+ * per-PEER, not per-topic:
8
+ *
9
+ * - **Batching.** Pulls to the same peer that arrive within {@link BATCH_WINDOW_MS} coalesce
10
+ * into ONE fetch stream carrying a batch key ({@link batchRootsFetchKey}), so a directory-
11
+ * scale join pays the ~2-RTT multistream-select negotiation once per peer instead of once
12
+ * per contest. A single pending topic skips the batch key entirely (the common single-board
13
+ * case has zero new wire surface). A responder that predates the batch key answers
14
+ * NOT_FOUND (or garbage), and the puller falls back to today's per-topic keys.
15
+ * - **Budget.** At most {@link COLD_START_PEER_FETCH_LIMIT} concurrent fetch streams per peer
16
+ * across ALL contests, under libp2p's default per-protocol caps (32 inbound on the
17
+ * responder, 64 outbound on us — both enforced PER CONNECTION per direction, so one
18
+ * connection's budget is exactly the scope of the remote cap; other users of a shared
19
+ * seeder arrive on their own connections and do not eat these slots). 24 rather than the
20
+ * full 32 because running at the cliff still resets: our slot frees when the response
21
+ * lands, but the responder only decrements its count when it sees the stream *close*, so
22
+ * back-to-back reuse races that bookkeeping — and the same connection can carry fetch
23
+ * streams the budget cannot see (the host's own IPNS-over-pubsub record fetches ride the
24
+ * same protocol; so would a second voter on the shared node).
25
+ * - **Retry.** A THROWN fetch retries with full-jittered exponential backoff until
26
+ * {@link COLD_START_FETCH_DEADLINE_MS} — the safety net for a responder saturated by
27
+ * streams the budget cannot see. While the cap is saturated every freed slot is instantly
28
+ * retaken, so a fixed attempt count can lose the race and strand a board (measured: no
29
+ * retry → 32/63 boards converge; 5 fixed retries → 53/63; retry-to-deadline → 63/63). Only
30
+ * a throw retries — a definitive `undefined`/`null` ("no record") returns as-is — and a
31
+ * pull whose contest was torn down (`isLive()` false) abandons quietly. Each attempt (not
32
+ * the whole loop, so a backoff sleep never holds a slot) passes through the budget; queue
33
+ * wait counts against the same deadline.
34
+ */
35
+ export interface RootPuller {
36
+ /**
37
+ * Pull one topic's root record from one peer: the decoded record, or `null`/`undefined`
38
+ * ("no record", definitive), or a rejection (unreachable peer past the deadline, or a
39
+ * garbage answer). `isLive` is polled between retries and before resolving, so a contest
40
+ * left mid-pull abandons instead of holding work alive.
41
+ */
42
+ pull(peer: PeerId, topic: string, isLive: () => boolean): Promise<FetchRootRecord | null | undefined>;
43
+ }
44
+ /** See the budget note on {@link RootPuller}. */
45
+ export declare const COLD_START_PEER_FETCH_LIMIT = 24;
46
+ /** See the retry note on {@link RootPuller}. */
47
+ export declare const COLD_START_FETCH_DEADLINE_MS = 30000;
48
+ /** Build the voter-wide puller over the host's fetch service. */
49
+ export declare function makeRootPuller(fetch: FetchServiceLike): RootPuller;
@@ -0,0 +1,140 @@
1
+ import pLimit from "p-limit";
2
+ import { batchRootsFetchKey, decodeBatchRootsResponse, decodeRootRecord, rootFetchKey, MAX_BATCH_ROOT_KEYS } from "../transport/messages.js";
3
+ /** See the budget note on {@link RootPuller}. */
4
+ export const COLD_START_PEER_FETCH_LIMIT = 24;
5
+ /** See the retry note on {@link RootPuller}. */
6
+ export const COLD_START_FETCH_DEADLINE_MS = 30_000;
7
+ const COLD_START_FETCH_BACKOFF_MS = 400;
8
+ const COLD_START_FETCH_BACKOFF_CAP_MS = 4_000;
9
+ /**
10
+ * How long a peer's first pending pull waits for same-peer company before its batch flushes.
11
+ * A directory join fires all its cold starts in one synchronous burst, so one tick would
12
+ * usually do; a few ms of slack covers joins interleaved with per-contest async work (topic
13
+ * hashing, chain-client setup) without adding perceptible latency to a lone join.
14
+ */
15
+ const BATCH_WINDOW_MS = 20;
16
+ /**
17
+ * One `pLimit(limitPerPeer)` per peer id, created on first use and dropped once its queue
18
+ * drains, so a long-lived voter does not accumulate limiters for every peer it ever
19
+ * cold-started against.
20
+ */
21
+ function makePerPeerBudget(limitPerPeer) {
22
+ const limiters = new Map();
23
+ return async (peerId, task) => {
24
+ let limiter = limiters.get(peerId);
25
+ if (limiter === undefined) {
26
+ limiter = pLimit(limitPerPeer);
27
+ limiters.set(peerId, limiter);
28
+ }
29
+ try {
30
+ return await limiter(task);
31
+ }
32
+ finally {
33
+ if (limiter.activeCount === 0 && limiter.pendingCount === 0)
34
+ limiters.delete(peerId);
35
+ }
36
+ };
37
+ }
38
+ /** Build the voter-wide puller over the host's fetch service. */
39
+ export function makeRootPuller(fetch) {
40
+ const budget = makePerPeerBudget(COLD_START_PEER_FETCH_LIMIT);
41
+ const pending = new Map();
42
+ /** One budgeted+retried fetch of one key (see the retry note on {@link RootPuller}). */
43
+ const fetchWithRetry = async (peer, key, isLive) => {
44
+ const deadline = Date.now() + COLD_START_FETCH_DEADLINE_MS;
45
+ let lastError;
46
+ for (let attempt = 0;; attempt++) {
47
+ if (attempt > 0) {
48
+ if (!isLive() || Date.now() >= deadline)
49
+ break; // left or out of time
50
+ const ceiling = Math.min(COLD_START_FETCH_BACKOFF_CAP_MS, COLD_START_FETCH_BACKOFF_MS * 2 ** (attempt - 1));
51
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * ceiling));
52
+ if (!isLive())
53
+ return undefined; // left mid-backoff — abandon quietly
54
+ }
55
+ try {
56
+ return await budget(peer.toString(), () => fetch.fetch(peer, key));
57
+ }
58
+ catch (error) {
59
+ lastError = error; // transient (e.g. responder over its inbound-stream cap) — back off and retry
60
+ }
61
+ }
62
+ throw lastError;
63
+ };
64
+ /** The per-topic path: today's `<topic>/root` key; a garbage answer rejects the waiters. */
65
+ const pullSingle = async (peer, topic, waiters) => {
66
+ try {
67
+ const value = await fetchWithRetry(peer, rootFetchKey(topic), () => waiters.some((w) => w.isLive()));
68
+ const record = value === undefined || value === null ? value : decodeRootRecord(value);
69
+ waiters.forEach((w) => w.resolve(record));
70
+ }
71
+ catch (error) {
72
+ waiters.forEach((w) => w.reject(error));
73
+ }
74
+ };
75
+ /**
76
+ * The batch path: one stream, one key carrying every pending topic, answers distributed by
77
+ * request order. NOT_FOUND (a responder without the batch key), a malformed response, or a
78
+ * length mismatch all degrade to the per-topic path — never to silence.
79
+ */
80
+ const pullBatch = async (peer, topics) => {
81
+ const order = [...topics.keys()];
82
+ const isLive = () => [...topics.values()].some((waiters) => waiters.some((w) => w.isLive()));
83
+ try {
84
+ const value = await fetchWithRetry(peer, batchRootsFetchKey(order), isLive);
85
+ if (value !== undefined && value !== null) {
86
+ let records;
87
+ try {
88
+ records = decodeBatchRootsResponse(value);
89
+ }
90
+ catch {
91
+ records = undefined; // hostile/buggy answer — fall through to per-topic
92
+ }
93
+ if (records !== undefined && records.length === order.length) {
94
+ order.forEach((topic, i) => topics.get(topic).forEach((w) => w.resolve(records[i] ?? null)));
95
+ return;
96
+ }
97
+ }
98
+ // Old responder (NOT_FOUND) or malformed batch answer: degrade to per-topic keys.
99
+ await Promise.all([...topics.entries()].map(([topic, waiters]) => pullSingle(peer, topic, waiters)));
100
+ }
101
+ catch (error) {
102
+ for (const waiters of topics.values())
103
+ waiters.forEach((w) => w.reject(error));
104
+ }
105
+ };
106
+ const flush = (peerId) => {
107
+ const batch = pending.get(peerId);
108
+ if (batch === undefined)
109
+ return;
110
+ pending.delete(peerId);
111
+ clearTimeout(batch.timer);
112
+ // A lone topic keeps today's per-topic key — no batch envelope for the common
113
+ // single-board join; two or more ride one batch stream.
114
+ if (batch.topics.size === 1) {
115
+ for (const [topic, waiters] of batch.topics)
116
+ void pullSingle(batch.peer, topic, waiters);
117
+ }
118
+ else {
119
+ void pullBatch(batch.peer, batch.topics);
120
+ }
121
+ };
122
+ return {
123
+ pull: (peer, topic, isLive) => new Promise((resolve, reject) => {
124
+ const peerId = peer.toString();
125
+ let batch = pending.get(peerId);
126
+ if (batch === undefined) {
127
+ const timer = setTimeout(() => flush(peerId), BATCH_WINDOW_MS);
128
+ timer.unref?.();
129
+ batch = { peer, topics: new Map(), timer };
130
+ pending.set(peerId, batch);
131
+ }
132
+ const waiters = batch.topics.get(topic) ?? [];
133
+ waiters.push({ isLive, resolve, reject });
134
+ batch.topics.set(topic, waiters);
135
+ // A full batch flushes immediately; the next pull opens a fresh window.
136
+ if (batch.topics.size >= MAX_BATCH_ROOT_KEYS)
137
+ flush(peerId);
138
+ })
139
+ };
140
+ }