@optimystic/db-p2p 0.28.0 → 1.0.0-beta.1

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 (82) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +0 -10
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +4 -2
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/cluster-policy.d.ts +101 -18
  6. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-policy.js +153 -30
  8. package/dist/src/cluster/cluster-policy.js.map +1 -1
  9. package/dist/src/cluster/cluster-repo.d.ts +75 -16
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +195 -24
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +4 -2
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +4 -2
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/service.d.ts +12 -9
  18. package/dist/src/cluster/service.d.ts.map +1 -1
  19. package/dist/src/cluster/service.js +6 -6
  20. package/dist/src/cluster/service.js.map +1 -1
  21. package/dist/src/dispute/service.d.ts +1 -4
  22. package/dist/src/dispute/service.d.ts.map +1 -1
  23. package/dist/src/dispute/service.js +2 -1
  24. package/dist/src/dispute/service.js.map +1 -1
  25. package/dist/src/libp2p-key-network.d.ts +8 -2
  26. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  27. package/dist/src/libp2p-key-network.js +8 -2
  28. package/dist/src/libp2p-key-network.js.map +1 -1
  29. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  30. package/dist/src/libp2p-node-base.js +22 -19
  31. package/dist/src/libp2p-node-base.js.map +1 -1
  32. package/dist/src/logger.d.ts +28 -1
  33. package/dist/src/logger.d.ts.map +1 -1
  34. package/dist/src/logger.js +143 -1
  35. package/dist/src/logger.js.map +1 -1
  36. package/dist/src/network/network-manager-service.d.ts +1 -4
  37. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  38. package/dist/src/network/network-manager-service.js +2 -1
  39. package/dist/src/network/network-manager-service.js.map +1 -1
  40. package/dist/src/repo/cluster-coordinator.d.ts +16 -1
  41. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  42. package/dist/src/repo/cluster-coordinator.js +66 -7
  43. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  44. package/dist/src/repo/coordinator-repo.d.ts +188 -14
  45. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  46. package/dist/src/repo/coordinator-repo.js +714 -110
  47. package/dist/src/repo/coordinator-repo.js.map +1 -1
  48. package/dist/src/repo/service.d.ts +9 -6
  49. package/dist/src/repo/service.d.ts.map +1 -1
  50. package/dist/src/repo/service.js +4 -5
  51. package/dist/src/repo/service.js.map +1 -1
  52. package/dist/src/storage/storage-repo.d.ts +6 -0
  53. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  54. package/dist/src/storage/storage-repo.js +62 -2
  55. package/dist/src/storage/storage-repo.js.map +1 -1
  56. package/dist/src/sync/service.d.ts +1 -2
  57. package/dist/src/sync/service.d.ts.map +1 -1
  58. package/dist/src/sync/service.js +2 -1
  59. package/dist/src/sync/service.js.map +1 -1
  60. package/dist/src/testing/mesh-harness.d.ts +7 -1
  61. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  62. package/dist/src/testing/mesh-harness.js +2 -1
  63. package/dist/src/testing/mesh-harness.js.map +1 -1
  64. package/package.json +2 -2
  65. package/readme.md +19 -0
  66. package/src/cluster/block-transfer-service.ts +4 -8
  67. package/src/cluster/cluster-policy.ts +196 -36
  68. package/src/cluster/cluster-repo.ts +2662 -2488
  69. package/src/cluster/quorum-restore.ts +4 -2
  70. package/src/cluster/service.ts +14 -9
  71. package/src/dispute/service.ts +3 -3
  72. package/src/libp2p-key-network.ts +8 -2
  73. package/src/libp2p-node-base.ts +22 -19
  74. package/src/logger.ts +196 -2
  75. package/src/network/network-manager-service.ts +414 -414
  76. package/src/protocol-client.ts +196 -196
  77. package/src/repo/cluster-coordinator.ts +1113 -1039
  78. package/src/repo/coordinator-repo.ts +2675 -1937
  79. package/src/repo/service.ts +12 -9
  80. package/src/storage/storage-repo.ts +65 -2
  81. package/src/sync/service.ts +3 -5
  82. package/src/testing/mesh-harness.ts +8 -1
@@ -1,414 +1,414 @@
1
- import type { Startable, Logger, PeerId, Libp2p } from '@libp2p/interface'
2
- import { peerIdFromString } from '@libp2p/peer-id'
3
- import type { FretService } from 'p2p-fret'
4
- import { hashKey } from 'p2p-fret'
5
- import { toString as u8ToString } from 'uint8arrays/to-string'
6
- import type { IPeerReputation } from '../reputation/types.js'
7
- import { PenaltyReason } from '../reputation/types.js'
8
- import { RebalanceMonitor, type RebalanceMonitorConfig } from '../cluster/rebalance-monitor.js'
9
- import { SpreadOnChurnMonitor, type SpreadOnChurnConfig, type SpreadOnChurnDeps } from '../cluster/spread-on-churn.js'
10
- import type { PartitionDetector } from '../cluster/partition-detector.js'
11
- import type { ArachnodeFretAdapter } from '../storage/arachnode-fret-adapter.js'
12
-
13
- export type NetworkManagerServiceInit = {
14
- clusterSize?: number
15
- seedKeys?: Uint8Array[]
16
- estimation?: { samples: number, kth: number, timeoutMs: number, ttlMs: number }
17
- readiness?: { minPeers: number, maxWaitMs: number }
18
- cacheTTLs?: { coordinatorMs: number, clusterMs: number }
19
- expectedRemotes?: boolean
20
- allowClusterDownsize?: boolean
21
- clusterSizeTolerance?: number
22
- }
23
-
24
- type Components = {
25
- logger: { forComponent: (name: string) => Logger },
26
- registrar: { handle: (...args: any[]) => Promise<void>, unhandle: (...args: any[]) => Promise<void> },
27
- libp2p?: Libp2p
28
- }
29
-
30
- interface WithFretService {
31
- services?: { fret?: FretService }
32
- }
33
-
34
- export class NetworkManagerService implements Startable {
35
- private running = false
36
- private readonly log: Logger
37
- private readonly cfg: Required<NetworkManagerServiceInit>
38
- private readyPromise: Promise<void> | null = null
39
- private readonly coordinatorCache = new Map<string, { id: PeerId, expires: number }>()
40
- private readonly clusterCache = new Map<string, { ids: PeerId[], expires: number }>()
41
- private lastEstimate: { estimate: number, samples: number, updated: number } | null = null
42
- private reputation?: IPeerReputation
43
- private libp2pRef: Libp2p | undefined
44
- private rebalanceMonitor?: RebalanceMonitor
45
- private spreadOnChurnMonitor?: SpreadOnChurnMonitor
46
-
47
- constructor(private readonly components: Components, init: NetworkManagerServiceInit = {}) {
48
- this.log = components.logger.forComponent('db-p2p:network-manager')
49
- this.cfg = {
50
- clusterSize: init.clusterSize ?? 1,
51
- seedKeys: init.seedKeys ?? [],
52
- estimation: init.estimation ?? { samples: 8, kth: 5, timeoutMs: 1000, ttlMs: 60_000 },
53
- readiness: init.readiness ?? { minPeers: 1, maxWaitMs: 2000 },
54
- cacheTTLs: init.cacheTTLs ?? { coordinatorMs: 30 * 60_000, clusterMs: 5 * 60_000 },
55
- expectedRemotes: init.expectedRemotes ?? false,
56
- allowClusterDownsize: init.allowClusterDownsize ?? true,
57
- clusterSizeTolerance: init.clusterSizeTolerance ?? 0.5
58
- }
59
- }
60
-
61
- /** The cluster size this instance actually resolved to, for `assertClusterSizeCoupling`. */
62
- get effectiveClusterSize(): number {
63
- return this.cfg.clusterSize;
64
- }
65
-
66
- setLibp2p(libp2p: Libp2p): void {
67
- this.libp2pRef = libp2p;
68
- }
69
-
70
- setReputation(reputation: IPeerReputation): void {
71
- this.reputation = reputation;
72
- }
73
-
74
- /**
75
- * Initialize the rebalance monitor. Call after libp2p, FRET, and adapter are available.
76
- */
77
- initRebalanceMonitor(
78
- partitionDetector: PartitionDetector,
79
- fretAdapter: ArachnodeFretAdapter,
80
- trackedBlocks: Set<string>,
81
- config?: RebalanceMonitorConfig
82
- ): RebalanceMonitor {
83
- const libp2p = this.getLibp2p()
84
- const fret = this.getFret()
85
- if (!libp2p || !fret) {
86
- throw new Error('Cannot init RebalanceMonitor: libp2p or FRET not available')
87
- }
88
- // trackedBlocks is the shared owned-block set: the node passes the SAME Set instance into
89
- // both monitor inits so spread and rebalance act on one source of truth and never drift.
90
- this.rebalanceMonitor = new RebalanceMonitor(
91
- { libp2p, fret, partitionDetector, fretAdapter, trackedBlocks },
92
- config
93
- )
94
- return this.rebalanceMonitor
95
- }
96
-
97
- getRebalanceMonitor(): RebalanceMonitor | undefined {
98
- return this.rebalanceMonitor
99
- }
100
-
101
- /**
102
- * Initialize the spread-on-churn monitor. Call after libp2p, FRET are available.
103
- * Caller provides repo and peerNetwork (not held by NetworkManagerService directly),
104
- * plus the node's protocolPrefix (/optimystic/<networkName>) so churn pushes dial the
105
- * same block-transfer protocol the node registers its handler under.
106
- */
107
- initSpreadOnChurnMonitor(
108
- partitionDetector: PartitionDetector,
109
- repo: SpreadOnChurnDeps['repo'],
110
- peerNetwork: SpreadOnChurnDeps['peerNetwork'],
111
- clusterSize: number,
112
- protocolPrefix: string,
113
- trackedBlocks: Set<string>,
114
- config?: Partial<SpreadOnChurnConfig>
115
- ): SpreadOnChurnMonitor {
116
- const libp2p = this.getLibp2p()
117
- const fret = this.getFret()
118
- if (!libp2p || !fret) {
119
- throw new Error('Cannot init SpreadOnChurnMonitor: libp2p or FRET not available')
120
- }
121
- // protocolPrefix MUST flow into SpreadOnChurnDeps: performSpread builds its
122
- // BlockTransferClient with it, and the receiving node registers the block-transfer
123
- // handler under /optimystic/<networkName>. A missing/empty prefix dials the wrong
124
- // protocol and every churn push fails to connect.
125
- // trackedBlocks is the shared owned-block set: the node passes the SAME Set instance into
126
- // both monitor inits so spread and rebalance act on one source of truth and never drift.
127
- this.spreadOnChurnMonitor = new SpreadOnChurnMonitor(
128
- { libp2p, fret, partitionDetector, repo, peerNetwork, clusterSize, protocolPrefix, trackedBlocks },
129
- config
130
- )
131
- return this.spreadOnChurnMonitor
132
- }
133
-
134
- getSpreadOnChurnMonitor(): SpreadOnChurnMonitor | undefined {
135
- return this.spreadOnChurnMonitor
136
- }
137
-
138
- private getLibp2p(): Libp2p | undefined {
139
- return this.libp2pRef ?? this.components.libp2p;
140
- }
141
-
142
- private getFret(): FretService | undefined {
143
- const libp2p = this.getLibp2p();
144
- if (!libp2p) {
145
- return undefined;
146
- }
147
- return (libp2p as unknown as WithFretService).services?.fret;
148
- }
149
-
150
- get [Symbol.toStringTag](): string { return '@libp2p/network-manager' }
151
-
152
- async start(): Promise<void> {
153
- if (this.running) return
154
- this.running = true
155
- // Do not call ready() here; libp2p components may not be fully set yet.
156
- // Consumers (e.g., CLI) should invoke ready() after node.start().
157
- }
158
-
159
- async stop(): Promise<void> {
160
- if (this.rebalanceMonitor) {
161
- await this.rebalanceMonitor.stop()
162
- }
163
- if (this.spreadOnChurnMonitor) {
164
- await this.spreadOnChurnMonitor.stop()
165
- }
166
- this.running = false
167
- }
168
-
169
- async ready(): Promise<void> {
170
- if (this.readyPromise) return this.readyPromise;
171
- this.readyPromise = (async () => {
172
- const results = await Promise.allSettled(
173
- (this.cfg.seedKeys ?? []).map(k => this.seedKey(k))
174
- );
175
- const failures = results.filter(r => r.status === 'rejected');
176
- if (failures.length > 0) {
177
- this.log('Failed to seed %d keys', failures.length);
178
- }
179
- await new Promise(r => setTimeout(r, 50));
180
- })();
181
- return this.readyPromise;
182
- }
183
-
184
- private async seedKey(key: Uint8Array): Promise<void> {
185
- const fret = this.getFret();
186
- if (!fret) {
187
- throw new Error('FRET service not available for seeding keys');
188
- }
189
- const coord = await hashKey(key);
190
- fret.getNeighbors(coord, 'both', 1);
191
- }
192
-
193
- private toCacheKey(key: Uint8Array): string {
194
- return u8ToString(key, 'base64url')
195
- }
196
-
197
- private getKnownPeers(): PeerId[] {
198
- const libp2p = this.getLibp2p();
199
- if (!libp2p) {
200
- return [];
201
- }
202
- const selfId: PeerId = libp2p.peerId;
203
- const storePeers: Array<{ id: PeerId }> = (libp2p.peerStore as any)?.getPeers?.() ?? [];
204
- const connPeers: PeerId[] = (libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer);
205
- const all = [...storePeers.map(p => p.id), ...connPeers];
206
- const uniq = all.filter((p, i) => all.findIndex(x => x.toString() === p.toString()) === i);
207
- return uniq.filter((pid: PeerId) => pid.toString() !== selfId.toString());
208
- }
209
-
210
- getStatus(): { mode: 'alone' | 'healthy' | 'degraded', connections: number } {
211
- const libp2p = this.getLibp2p();
212
- if (!libp2p) {
213
- return { mode: this.cfg.expectedRemotes ? 'degraded' : 'alone', connections: 0 };
214
- }
215
- const peers: Array<{ id: PeerId }> = (libp2p.peerStore as any)?.getPeers?.() ?? [];
216
- const remotes = peers.filter(p => p.id.toString() !== libp2p.peerId.toString()).length;
217
- if (remotes === 0) {
218
- return { mode: this.cfg.expectedRemotes ? 'degraded' : 'alone', connections: 0 };
219
- }
220
- return { mode: 'healthy', connections: remotes };
221
- }
222
-
223
- async awaitHealthy(minRemotes: number, timeoutMs: number): Promise<boolean> {
224
- const start = Date.now()
225
- while (Date.now() - start < timeoutMs) {
226
- const libp2p = this.getLibp2p()
227
- if (libp2p) {
228
- // Require actual active connections, not just peerStore knowledge
229
- const connections = libp2p.getConnections?.() ?? []
230
- const connectedPeers = new Set(connections.map((c: any) => c.remotePeer.toString()))
231
- if (connectedPeers.size >= minRemotes) {
232
- this.log('awaitHealthy: satisfied with %d connections', connectedPeers.size)
233
- return true
234
- }
235
- }
236
- await new Promise(r => setTimeout(r, 100))
237
- }
238
- // Final check
239
- const libp2p = this.getLibp2p()
240
- if (libp2p) {
241
- const connections = libp2p.getConnections?.() ?? []
242
- const connectedPeers = new Set(connections.map((c: any) => c.remotePeer.toString()))
243
- const satisfied = connectedPeers.size >= minRemotes
244
- this.log('awaitHealthy: timeout - %d connections (needed %d)', connectedPeers.size, minRemotes)
245
- return satisfied
246
- }
247
- return false
248
- }
249
-
250
- /**
251
- * Record a misbehaving peer. Delegates to IPeerReputation if available.
252
- */
253
- reportBadPeer(peerId: PeerId, reason: PenaltyReason = PenaltyReason.ConnectionFailure): void {
254
- this.reputation?.reportPeer(peerId.toString(), reason)
255
- }
256
-
257
- private isBlacklisted(peerId: PeerId): boolean {
258
- return this.reputation?.isBanned(peerId.toString()) ?? false
259
- }
260
-
261
- recordCoordinator(key: Uint8Array, peerId: PeerId): void {
262
- const k = this.toCacheKey(key)
263
- this.coordinatorCache.set(k, { id: peerId, expires: Date.now() + this.cfg.cacheTTLs.coordinatorMs })
264
- }
265
-
266
- /**
267
- * Find the nearest peer to the provided content key using FRET,
268
- * falling back to self if FRET is unavailable.
269
- */
270
- private async findNearestPeerToKey(key: Uint8Array): Promise<PeerId> {
271
- const fret = this.getFret();
272
- const libp2p = this.getLibp2p();
273
-
274
- if (!libp2p) {
275
- throw new Error('Libp2p not initialized');
276
- }
277
-
278
- if (fret) {
279
- const coord = await hashKey(key);
280
- const neighbors = fret.getNeighbors(coord, 'both', 1);
281
- if (neighbors.length > 0) {
282
- const pidStr = neighbors[0];
283
- if (pidStr) {
284
- const pid = peerIdFromString(pidStr);
285
- if (!this.isBlacklisted(pid)) {
286
- return pid;
287
- }
288
- }
289
- }
290
- }
291
-
292
- // Fallback: choose among self + connected peers + known peers by distance to key
293
- const connected: PeerId[] = (libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer);
294
- const candidates = [libp2p.peerId, ...connected, ...this.getKnownPeers()]
295
- .filter((p, i, arr) => arr.findIndex(x => x.toString() === p.toString()) === i)
296
- .filter(p => !this.isBlacklisted(p));
297
-
298
- if (candidates.length === 0) {
299
- return libp2p.peerId;
300
- }
301
-
302
- const best = candidates.reduce((best: PeerId, cur: PeerId) =>
303
- this.lexLess(this.xor(best.toMultihash().bytes, key), this.xor(cur.toMultihash().bytes, key)) ? best : cur
304
- , candidates[0]!);
305
- return best;
306
- }
307
-
308
- /**
309
- * Compute cluster using FRET's assembleCohort for content-addressed peer selection.
310
- */
311
- async getCluster(key: Uint8Array): Promise<PeerId[]> {
312
- const ck = this.toCacheKey(key);
313
- const cached = this.clusterCache.get(ck);
314
- if (cached && cached.expires > Date.now()) {
315
- return cached.ids;
316
- }
317
-
318
- const fret = this.getFret();
319
- const libp2p = this.getLibp2p();
320
-
321
- if (!libp2p) {
322
- throw new Error('Libp2p not initialized');
323
- }
324
-
325
- if (fret) {
326
- const coord = await hashKey(key);
327
- const diag: any = (fret as any).getDiagnostics?.() ?? {};
328
- const estimate = typeof diag.estimate === 'number' ? diag.estimate : (typeof diag.n === 'number' ? diag.n : undefined);
329
- const targetSize = Math.max(1, Math.min(this.cfg.clusterSize, Number.isFinite(estimate) ? (estimate as number) : this.cfg.clusterSize));
330
- const cohortIds = fret.assembleCohort(coord, targetSize);
331
- const ids = cohortIds
332
- .map(idStr => {
333
- try {
334
- return peerIdFromString(idStr);
335
- } catch (error) {
336
- this.log('Invalid peer ID in cohort: %s, %o', idStr, error);
337
- return null;
338
- }
339
- })
340
- .filter((pid): pid is PeerId => pid !== null && !this.isBlacklisted(pid));
341
-
342
- if (ids.length > 0) {
343
- this.clusterCache.set(ck, { ids, expires: Date.now() + this.cfg.cacheTTLs.clusterMs });
344
- this.lastEstimate = estimate != null ? { estimate, samples: diag.samples ?? 0, updated: Date.now() } : this.lastEstimate;
345
- return ids;
346
- }
347
- }
348
-
349
- // Fallback: peer-centric clustering if FRET unavailable
350
- const anchor = await this.findNearestPeerToKey(key);
351
- const anchorMh = anchor.toMultihash().bytes;
352
- const connected: PeerId[] = (libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer);
353
- const candidates = [anchor, libp2p.peerId, ...connected, ...this.getKnownPeers()]
354
- .filter((p, idx, arr) => !this.isBlacklisted(p) && arr.findIndex(x => x.toString() === p.toString()) === idx);
355
- const sorted = candidates.sort((a, b) => this.lexLess(this.xor(a.toMultihash().bytes, anchorMh), this.xor(b.toMultihash().bytes, anchorMh)) ? -1 : 1);
356
- const K = Math.min(this.cfg.clusterSize, sorted.length);
357
- const ids = sorted.slice(0, K);
358
- this.clusterCache.set(ck, { ids, expires: Date.now() + this.cfg.cacheTTLs.clusterMs });
359
- return ids;
360
- }
361
-
362
- async getCoordinator(key: Uint8Array): Promise<PeerId> {
363
- const ck = this.toCacheKey(key);
364
- const hit = this.coordinatorCache.get(ck);
365
- if (hit) {
366
- if (hit.expires > Date.now()) {
367
- return hit.id;
368
- } else {
369
- this.coordinatorCache.delete(ck);
370
- }
371
- }
372
-
373
- const cluster = await this.getCluster(key);
374
- const libp2p = this.getLibp2p();
375
- if (!libp2p) {
376
- throw new Error('Libp2p not initialized');
377
- }
378
- // Prefer non-banned, non-deprioritized peers; fall back to deprioritized before self
379
- const candidate = cluster
380
- .filter(p => !this.isBlacklisted(p))
381
- .sort((a, b) => (this.reputation?.getScore(a.toString()) ?? 0) - (this.reputation?.getScore(b.toString()) ?? 0))
382
- [0] ?? libp2p.peerId;
383
- this.recordCoordinator(key, candidate);
384
- return candidate;
385
- }
386
-
387
- private xor(a: Uint8Array, b: Uint8Array): Uint8Array {
388
- const len = Math.max(a.length, b.length)
389
- const out = new Uint8Array(len)
390
- for (let i = 0; i < len; i++) {
391
- const ai = a[a.length - 1 - i] ?? 0
392
- const bi = b[b.length - 1 - i] ?? 0
393
- out[len - 1 - i] = ai ^ bi
394
- }
395
- return out
396
- }
397
-
398
- private lexLess(a: Uint8Array, b: Uint8Array): boolean {
399
- const len = Math.max(a.length, b.length)
400
- for (let i = 0; i < len; i++) {
401
- const av = a[i] ?? 0
402
- const bv = b[i] ?? 0
403
- if (av < bv) return true
404
- if (av > bv) return false
405
- }
406
- return false
407
- }
408
- }
409
-
410
- export function networkManagerService(init: NetworkManagerServiceInit = {}) {
411
- return (components: Components) => new NetworkManagerService(components, init)
412
- }
413
-
414
-
1
+ import type { Startable, PeerId, Libp2p } from '@libp2p/interface'
2
+ import { peerIdFromString } from '@libp2p/peer-id'
3
+ import type { FretService } from 'p2p-fret'
4
+ import { hashKey } from 'p2p-fret'
5
+ import { toString as u8ToString } from 'uint8arrays/to-string'
6
+ import type { IPeerReputation } from '../reputation/types.js'
7
+ import { PenaltyReason } from '../reputation/types.js'
8
+ import { RebalanceMonitor, type RebalanceMonitorConfig } from '../cluster/rebalance-monitor.js'
9
+ import { SpreadOnChurnMonitor, type SpreadOnChurnConfig, type SpreadOnChurnDeps } from '../cluster/spread-on-churn.js'
10
+ import type { PartitionDetector } from '../cluster/partition-detector.js'
11
+ import type { ArachnodeFretAdapter } from '../storage/arachnode-fret-adapter.js'
12
+ import { createLogger, type Logger } from '../logger.js'
13
+
14
+ export type NetworkManagerServiceInit = {
15
+ clusterSize?: number
16
+ seedKeys?: Uint8Array[]
17
+ estimation?: { samples: number, kth: number, timeoutMs: number, ttlMs: number }
18
+ readiness?: { minPeers: number, maxWaitMs: number }
19
+ cacheTTLs?: { coordinatorMs: number, clusterMs: number }
20
+ expectedRemotes?: boolean
21
+ allowClusterDownsize?: boolean
22
+ clusterSizeTolerance?: number
23
+ }
24
+
25
+ type Components = {
26
+ registrar: { handle: (...args: any[]) => Promise<void>, unhandle: (...args: any[]) => Promise<void> },
27
+ libp2p?: Libp2p
28
+ }
29
+
30
+ interface WithFretService {
31
+ services?: { fret?: FretService }
32
+ }
33
+
34
+ export class NetworkManagerService implements Startable {
35
+ private running = false
36
+ private readonly log: Logger
37
+ private readonly cfg: Required<NetworkManagerServiceInit>
38
+ private readyPromise: Promise<void> | null = null
39
+ private readonly coordinatorCache = new Map<string, { id: PeerId, expires: number }>()
40
+ private readonly clusterCache = new Map<string, { ids: PeerId[], expires: number }>()
41
+ private lastEstimate: { estimate: number, samples: number, updated: number } | null = null
42
+ private reputation?: IPeerReputation
43
+ private libp2pRef: Libp2p | undefined
44
+ private rebalanceMonitor?: RebalanceMonitor
45
+ private spreadOnChurnMonitor?: SpreadOnChurnMonitor
46
+
47
+ constructor(private readonly components: Components, init: NetworkManagerServiceInit = {}) {
48
+ this.log = createLogger('network-manager')
49
+ this.cfg = {
50
+ clusterSize: init.clusterSize ?? 1,
51
+ seedKeys: init.seedKeys ?? [],
52
+ estimation: init.estimation ?? { samples: 8, kth: 5, timeoutMs: 1000, ttlMs: 60_000 },
53
+ readiness: init.readiness ?? { minPeers: 1, maxWaitMs: 2000 },
54
+ cacheTTLs: init.cacheTTLs ?? { coordinatorMs: 30 * 60_000, clusterMs: 5 * 60_000 },
55
+ expectedRemotes: init.expectedRemotes ?? false,
56
+ allowClusterDownsize: init.allowClusterDownsize ?? true,
57
+ clusterSizeTolerance: init.clusterSizeTolerance ?? 0.5
58
+ }
59
+ }
60
+
61
+ /** The cluster size this instance actually resolved to, for `assertClusterSizeCoupling`. */
62
+ get effectiveClusterSize(): number {
63
+ return this.cfg.clusterSize;
64
+ }
65
+
66
+ setLibp2p(libp2p: Libp2p): void {
67
+ this.libp2pRef = libp2p;
68
+ }
69
+
70
+ setReputation(reputation: IPeerReputation): void {
71
+ this.reputation = reputation;
72
+ }
73
+
74
+ /**
75
+ * Initialize the rebalance monitor. Call after libp2p, FRET, and adapter are available.
76
+ */
77
+ initRebalanceMonitor(
78
+ partitionDetector: PartitionDetector,
79
+ fretAdapter: ArachnodeFretAdapter,
80
+ trackedBlocks: Set<string>,
81
+ config?: RebalanceMonitorConfig
82
+ ): RebalanceMonitor {
83
+ const libp2p = this.getLibp2p()
84
+ const fret = this.getFret()
85
+ if (!libp2p || !fret) {
86
+ throw new Error('Cannot init RebalanceMonitor: libp2p or FRET not available')
87
+ }
88
+ // trackedBlocks is the shared owned-block set: the node passes the SAME Set instance into
89
+ // both monitor inits so spread and rebalance act on one source of truth and never drift.
90
+ this.rebalanceMonitor = new RebalanceMonitor(
91
+ { libp2p, fret, partitionDetector, fretAdapter, trackedBlocks },
92
+ config
93
+ )
94
+ return this.rebalanceMonitor
95
+ }
96
+
97
+ getRebalanceMonitor(): RebalanceMonitor | undefined {
98
+ return this.rebalanceMonitor
99
+ }
100
+
101
+ /**
102
+ * Initialize the spread-on-churn monitor. Call after libp2p, FRET are available.
103
+ * Caller provides repo and peerNetwork (not held by NetworkManagerService directly),
104
+ * plus the node's protocolPrefix (/optimystic/<networkName>) so churn pushes dial the
105
+ * same block-transfer protocol the node registers its handler under.
106
+ */
107
+ initSpreadOnChurnMonitor(
108
+ partitionDetector: PartitionDetector,
109
+ repo: SpreadOnChurnDeps['repo'],
110
+ peerNetwork: SpreadOnChurnDeps['peerNetwork'],
111
+ clusterSize: number,
112
+ protocolPrefix: string,
113
+ trackedBlocks: Set<string>,
114
+ config?: Partial<SpreadOnChurnConfig>
115
+ ): SpreadOnChurnMonitor {
116
+ const libp2p = this.getLibp2p()
117
+ const fret = this.getFret()
118
+ if (!libp2p || !fret) {
119
+ throw new Error('Cannot init SpreadOnChurnMonitor: libp2p or FRET not available')
120
+ }
121
+ // protocolPrefix MUST flow into SpreadOnChurnDeps: performSpread builds its
122
+ // BlockTransferClient with it, and the receiving node registers the block-transfer
123
+ // handler under /optimystic/<networkName>. A missing/empty prefix dials the wrong
124
+ // protocol and every churn push fails to connect.
125
+ // trackedBlocks is the shared owned-block set: the node passes the SAME Set instance into
126
+ // both monitor inits so spread and rebalance act on one source of truth and never drift.
127
+ this.spreadOnChurnMonitor = new SpreadOnChurnMonitor(
128
+ { libp2p, fret, partitionDetector, repo, peerNetwork, clusterSize, protocolPrefix, trackedBlocks },
129
+ config
130
+ )
131
+ return this.spreadOnChurnMonitor
132
+ }
133
+
134
+ getSpreadOnChurnMonitor(): SpreadOnChurnMonitor | undefined {
135
+ return this.spreadOnChurnMonitor
136
+ }
137
+
138
+ private getLibp2p(): Libp2p | undefined {
139
+ return this.libp2pRef ?? this.components.libp2p;
140
+ }
141
+
142
+ private getFret(): FretService | undefined {
143
+ const libp2p = this.getLibp2p();
144
+ if (!libp2p) {
145
+ return undefined;
146
+ }
147
+ return (libp2p as unknown as WithFretService).services?.fret;
148
+ }
149
+
150
+ get [Symbol.toStringTag](): string { return '@libp2p/network-manager' }
151
+
152
+ async start(): Promise<void> {
153
+ if (this.running) return
154
+ this.running = true
155
+ // Do not call ready() here; libp2p components may not be fully set yet.
156
+ // Consumers (e.g., CLI) should invoke ready() after node.start().
157
+ }
158
+
159
+ async stop(): Promise<void> {
160
+ if (this.rebalanceMonitor) {
161
+ await this.rebalanceMonitor.stop()
162
+ }
163
+ if (this.spreadOnChurnMonitor) {
164
+ await this.spreadOnChurnMonitor.stop()
165
+ }
166
+ this.running = false
167
+ }
168
+
169
+ async ready(): Promise<void> {
170
+ if (this.readyPromise) return this.readyPromise;
171
+ this.readyPromise = (async () => {
172
+ const results = await Promise.allSettled(
173
+ (this.cfg.seedKeys ?? []).map(k => this.seedKey(k))
174
+ );
175
+ const failures = results.filter(r => r.status === 'rejected');
176
+ if (failures.length > 0) {
177
+ this.log('Failed to seed %d keys', failures.length);
178
+ }
179
+ await new Promise(r => setTimeout(r, 50));
180
+ })();
181
+ return this.readyPromise;
182
+ }
183
+
184
+ private async seedKey(key: Uint8Array): Promise<void> {
185
+ const fret = this.getFret();
186
+ if (!fret) {
187
+ throw new Error('FRET service not available for seeding keys');
188
+ }
189
+ const coord = await hashKey(key);
190
+ fret.getNeighbors(coord, 'both', 1);
191
+ }
192
+
193
+ private toCacheKey(key: Uint8Array): string {
194
+ return u8ToString(key, 'base64url')
195
+ }
196
+
197
+ private getKnownPeers(): PeerId[] {
198
+ const libp2p = this.getLibp2p();
199
+ if (!libp2p) {
200
+ return [];
201
+ }
202
+ const selfId: PeerId = libp2p.peerId;
203
+ const storePeers: Array<{ id: PeerId }> = (libp2p.peerStore as any)?.getPeers?.() ?? [];
204
+ const connPeers: PeerId[] = (libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer);
205
+ const all = [...storePeers.map(p => p.id), ...connPeers];
206
+ const uniq = all.filter((p, i) => all.findIndex(x => x.toString() === p.toString()) === i);
207
+ return uniq.filter((pid: PeerId) => pid.toString() !== selfId.toString());
208
+ }
209
+
210
+ getStatus(): { mode: 'alone' | 'healthy' | 'degraded', connections: number } {
211
+ const libp2p = this.getLibp2p();
212
+ if (!libp2p) {
213
+ return { mode: this.cfg.expectedRemotes ? 'degraded' : 'alone', connections: 0 };
214
+ }
215
+ const peers: Array<{ id: PeerId }> = (libp2p.peerStore as any)?.getPeers?.() ?? [];
216
+ const remotes = peers.filter(p => p.id.toString() !== libp2p.peerId.toString()).length;
217
+ if (remotes === 0) {
218
+ return { mode: this.cfg.expectedRemotes ? 'degraded' : 'alone', connections: 0 };
219
+ }
220
+ return { mode: 'healthy', connections: remotes };
221
+ }
222
+
223
+ async awaitHealthy(minRemotes: number, timeoutMs: number): Promise<boolean> {
224
+ const start = Date.now()
225
+ while (Date.now() - start < timeoutMs) {
226
+ const libp2p = this.getLibp2p()
227
+ if (libp2p) {
228
+ // Require actual active connections, not just peerStore knowledge
229
+ const connections = libp2p.getConnections?.() ?? []
230
+ const connectedPeers = new Set(connections.map((c: any) => c.remotePeer.toString()))
231
+ if (connectedPeers.size >= minRemotes) {
232
+ this.log('awaitHealthy: satisfied with %d connections', connectedPeers.size)
233
+ return true
234
+ }
235
+ }
236
+ await new Promise(r => setTimeout(r, 100))
237
+ }
238
+ // Final check
239
+ const libp2p = this.getLibp2p()
240
+ if (libp2p) {
241
+ const connections = libp2p.getConnections?.() ?? []
242
+ const connectedPeers = new Set(connections.map((c: any) => c.remotePeer.toString()))
243
+ const satisfied = connectedPeers.size >= minRemotes
244
+ this.log('awaitHealthy: timeout - %d connections (needed %d)', connectedPeers.size, minRemotes)
245
+ return satisfied
246
+ }
247
+ return false
248
+ }
249
+
250
+ /**
251
+ * Record a misbehaving peer. Delegates to IPeerReputation if available.
252
+ */
253
+ reportBadPeer(peerId: PeerId, reason: PenaltyReason = PenaltyReason.ConnectionFailure): void {
254
+ this.reputation?.reportPeer(peerId.toString(), reason)
255
+ }
256
+
257
+ private isBlacklisted(peerId: PeerId): boolean {
258
+ return this.reputation?.isBanned(peerId.toString()) ?? false
259
+ }
260
+
261
+ recordCoordinator(key: Uint8Array, peerId: PeerId): void {
262
+ const k = this.toCacheKey(key)
263
+ this.coordinatorCache.set(k, { id: peerId, expires: Date.now() + this.cfg.cacheTTLs.coordinatorMs })
264
+ }
265
+
266
+ /**
267
+ * Find the nearest peer to the provided content key using FRET,
268
+ * falling back to self if FRET is unavailable.
269
+ */
270
+ private async findNearestPeerToKey(key: Uint8Array): Promise<PeerId> {
271
+ const fret = this.getFret();
272
+ const libp2p = this.getLibp2p();
273
+
274
+ if (!libp2p) {
275
+ throw new Error('Libp2p not initialized');
276
+ }
277
+
278
+ if (fret) {
279
+ const coord = await hashKey(key);
280
+ const neighbors = fret.getNeighbors(coord, 'both', 1);
281
+ if (neighbors.length > 0) {
282
+ const pidStr = neighbors[0];
283
+ if (pidStr) {
284
+ const pid = peerIdFromString(pidStr);
285
+ if (!this.isBlacklisted(pid)) {
286
+ return pid;
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ // Fallback: choose among self + connected peers + known peers by distance to key
293
+ const connected: PeerId[] = (libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer);
294
+ const candidates = [libp2p.peerId, ...connected, ...this.getKnownPeers()]
295
+ .filter((p, i, arr) => arr.findIndex(x => x.toString() === p.toString()) === i)
296
+ .filter(p => !this.isBlacklisted(p));
297
+
298
+ if (candidates.length === 0) {
299
+ return libp2p.peerId;
300
+ }
301
+
302
+ const best = candidates.reduce((best: PeerId, cur: PeerId) =>
303
+ this.lexLess(this.xor(best.toMultihash().bytes, key), this.xor(cur.toMultihash().bytes, key)) ? best : cur
304
+ , candidates[0]!);
305
+ return best;
306
+ }
307
+
308
+ /**
309
+ * Compute cluster using FRET's assembleCohort for content-addressed peer selection.
310
+ */
311
+ async getCluster(key: Uint8Array): Promise<PeerId[]> {
312
+ const ck = this.toCacheKey(key);
313
+ const cached = this.clusterCache.get(ck);
314
+ if (cached && cached.expires > Date.now()) {
315
+ return cached.ids;
316
+ }
317
+
318
+ const fret = this.getFret();
319
+ const libp2p = this.getLibp2p();
320
+
321
+ if (!libp2p) {
322
+ throw new Error('Libp2p not initialized');
323
+ }
324
+
325
+ if (fret) {
326
+ const coord = await hashKey(key);
327
+ const diag: any = (fret as any).getDiagnostics?.() ?? {};
328
+ const estimate = typeof diag.estimate === 'number' ? diag.estimate : (typeof diag.n === 'number' ? diag.n : undefined);
329
+ const targetSize = Math.max(1, Math.min(this.cfg.clusterSize, Number.isFinite(estimate) ? (estimate as number) : this.cfg.clusterSize));
330
+ const cohortIds = fret.assembleCohort(coord, targetSize);
331
+ const ids = cohortIds
332
+ .map(idStr => {
333
+ try {
334
+ return peerIdFromString(idStr);
335
+ } catch (error) {
336
+ this.log('Invalid peer ID in cohort: %s, %o', idStr, error);
337
+ return null;
338
+ }
339
+ })
340
+ .filter((pid): pid is PeerId => pid !== null && !this.isBlacklisted(pid));
341
+
342
+ if (ids.length > 0) {
343
+ this.clusterCache.set(ck, { ids, expires: Date.now() + this.cfg.cacheTTLs.clusterMs });
344
+ this.lastEstimate = estimate != null ? { estimate, samples: diag.samples ?? 0, updated: Date.now() } : this.lastEstimate;
345
+ return ids;
346
+ }
347
+ }
348
+
349
+ // Fallback: peer-centric clustering if FRET unavailable
350
+ const anchor = await this.findNearestPeerToKey(key);
351
+ const anchorMh = anchor.toMultihash().bytes;
352
+ const connected: PeerId[] = (libp2p.getConnections?.() ?? []).map((c: any) => c.remotePeer);
353
+ const candidates = [anchor, libp2p.peerId, ...connected, ...this.getKnownPeers()]
354
+ .filter((p, idx, arr) => !this.isBlacklisted(p) && arr.findIndex(x => x.toString() === p.toString()) === idx);
355
+ const sorted = candidates.sort((a, b) => this.lexLess(this.xor(a.toMultihash().bytes, anchorMh), this.xor(b.toMultihash().bytes, anchorMh)) ? -1 : 1);
356
+ const K = Math.min(this.cfg.clusterSize, sorted.length);
357
+ const ids = sorted.slice(0, K);
358
+ this.clusterCache.set(ck, { ids, expires: Date.now() + this.cfg.cacheTTLs.clusterMs });
359
+ return ids;
360
+ }
361
+
362
+ async getCoordinator(key: Uint8Array): Promise<PeerId> {
363
+ const ck = this.toCacheKey(key);
364
+ const hit = this.coordinatorCache.get(ck);
365
+ if (hit) {
366
+ if (hit.expires > Date.now()) {
367
+ return hit.id;
368
+ } else {
369
+ this.coordinatorCache.delete(ck);
370
+ }
371
+ }
372
+
373
+ const cluster = await this.getCluster(key);
374
+ const libp2p = this.getLibp2p();
375
+ if (!libp2p) {
376
+ throw new Error('Libp2p not initialized');
377
+ }
378
+ // Prefer non-banned, non-deprioritized peers; fall back to deprioritized before self
379
+ const candidate = cluster
380
+ .filter(p => !this.isBlacklisted(p))
381
+ .sort((a, b) => (this.reputation?.getScore(a.toString()) ?? 0) - (this.reputation?.getScore(b.toString()) ?? 0))
382
+ [0] ?? libp2p.peerId;
383
+ this.recordCoordinator(key, candidate);
384
+ return candidate;
385
+ }
386
+
387
+ private xor(a: Uint8Array, b: Uint8Array): Uint8Array {
388
+ const len = Math.max(a.length, b.length)
389
+ const out = new Uint8Array(len)
390
+ for (let i = 0; i < len; i++) {
391
+ const ai = a[a.length - 1 - i] ?? 0
392
+ const bi = b[b.length - 1 - i] ?? 0
393
+ out[len - 1 - i] = ai ^ bi
394
+ }
395
+ return out
396
+ }
397
+
398
+ private lexLess(a: Uint8Array, b: Uint8Array): boolean {
399
+ const len = Math.max(a.length, b.length)
400
+ for (let i = 0; i < len; i++) {
401
+ const av = a[i] ?? 0
402
+ const bv = b[i] ?? 0
403
+ if (av < bv) return true
404
+ if (av > bv) return false
405
+ }
406
+ return false
407
+ }
408
+ }
409
+
410
+ export function networkManagerService(init: NetworkManagerServiceInit = {}) {
411
+ return (components: Components) => new NetworkManagerService(components, init)
412
+ }
413
+
414
+