@optimystic/db-p2p 0.14.1 → 0.16.3

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 (275) hide show
  1. package/README.md +616 -609
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +4 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +52 -3
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +129 -6
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts.map +1 -1
  10. package/dist/src/cluster/client.js +3 -1
  11. package/dist/src/cluster/client.js.map +1 -1
  12. package/dist/src/cluster/cluster-repo.d.ts +182 -11
  13. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  14. package/dist/src/cluster/cluster-repo.js +429 -69
  15. package/dist/src/cluster/cluster-repo.js.map +1 -1
  16. package/dist/src/cluster/peer-key-binding.d.ts +25 -0
  17. package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
  18. package/dist/src/cluster/peer-key-binding.js +39 -0
  19. package/dist/src/cluster/peer-key-binding.js.map +1 -0
  20. package/dist/src/cluster/quorum-restore.d.ts +72 -0
  21. package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
  22. package/dist/src/cluster/quorum-restore.js +0 -0
  23. package/dist/src/cluster/quorum-restore.js.map +1 -0
  24. package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  26. package/dist/src/cluster/rebalance-monitor.js +17 -2
  27. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  28. package/dist/src/cluster/service.d.ts.map +1 -1
  29. package/dist/src/cluster/service.js +7 -1
  30. package/dist/src/cluster/service.js.map +1 -1
  31. package/dist/src/cluster/spread-on-churn.d.ts +7 -0
  32. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  33. package/dist/src/cluster/spread-on-churn.js +4 -1
  34. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  35. package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
  36. package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
  37. package/dist/src/cluster/supermajority-coupling.js +27 -0
  38. package/dist/src/cluster/supermajority-coupling.js.map +1 -0
  39. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
  40. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
  41. package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
  42. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
  43. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
  44. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
  45. package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
  46. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
  47. package/dist/src/cohort-topic/host.d.ts +197 -27
  48. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  49. package/dist/src/cohort-topic/host.js +643 -87
  50. package/dist/src/cohort-topic/host.js.map +1 -1
  51. package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
  52. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
  53. package/dist/src/dispute/arbitrator-selection.js +105 -16
  54. package/dist/src/dispute/arbitrator-selection.js.map +1 -1
  55. package/dist/src/dispute/cascade.d.ts +7 -0
  56. package/dist/src/dispute/cascade.d.ts.map +1 -1
  57. package/dist/src/dispute/cascade.js +1 -1
  58. package/dist/src/dispute/cascade.js.map +1 -1
  59. package/dist/src/dispute/client.d.ts.map +1 -1
  60. package/dist/src/dispute/client.js +3 -2
  61. package/dist/src/dispute/client.js.map +1 -1
  62. package/dist/src/dispute/dispute-service.d.ts +32 -3
  63. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  64. package/dist/src/dispute/dispute-service.js +82 -9
  65. package/dist/src/dispute/dispute-service.js.map +1 -1
  66. package/dist/src/dispute/index.d.ts +1 -1
  67. package/dist/src/dispute/index.d.ts.map +1 -1
  68. package/dist/src/dispute/index.js +1 -1
  69. package/dist/src/dispute/index.js.map +1 -1
  70. package/dist/src/dispute/invalidation.d.ts +25 -0
  71. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  72. package/dist/src/dispute/invalidation.js +14 -2
  73. package/dist/src/dispute/invalidation.js.map +1 -1
  74. package/dist/src/dispute/service.d.ts.map +1 -1
  75. package/dist/src/dispute/service.js +7 -1
  76. package/dist/src/dispute/service.js.map +1 -1
  77. package/dist/src/index.d.ts +4 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +4 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/it-utility.d.ts +1 -1
  82. package/dist/src/it-utility.d.ts.map +1 -1
  83. package/dist/src/it-utility.js +4 -13
  84. package/dist/src/it-utility.js.map +1 -1
  85. package/dist/src/libp2p-key-network.d.ts +98 -1
  86. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  87. package/dist/src/libp2p-key-network.js +272 -17
  88. package/dist/src/libp2p-key-network.js.map +1 -1
  89. package/dist/src/libp2p-node-base.d.ts +12 -0
  90. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  91. package/dist/src/libp2p-node-base.js +441 -89
  92. package/dist/src/libp2p-node-base.js.map +1 -1
  93. package/dist/src/matchmaking/query-transport.d.ts +2 -0
  94. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  95. package/dist/src/matchmaking/query-transport.js +6 -0
  96. package/dist/src/matchmaking/query-transport.js.map +1 -1
  97. package/dist/src/matchmaking/traffic-validation.js +1 -0
  98. package/dist/src/matchmaking/traffic-validation.js.map +1 -1
  99. package/dist/src/network/network-manager-service.d.ts +2 -2
  100. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  101. package/dist/src/network/network-manager-service.js +8 -4
  102. package/dist/src/network/network-manager-service.js.map +1 -1
  103. package/dist/src/owned-block-seed.d.ts +27 -0
  104. package/dist/src/owned-block-seed.d.ts.map +1 -0
  105. package/dist/src/owned-block-seed.js +37 -0
  106. package/dist/src/owned-block-seed.js.map +1 -0
  107. package/dist/src/protocol-client.d.ts +1 -0
  108. package/dist/src/protocol-client.d.ts.map +1 -1
  109. package/dist/src/protocol-client.js +9 -2
  110. package/dist/src/protocol-client.js.map +1 -1
  111. package/dist/src/protocol-limits.d.ts +32 -0
  112. package/dist/src/protocol-limits.d.ts.map +1 -0
  113. package/dist/src/protocol-limits.js +32 -0
  114. package/dist/src/protocol-limits.js.map +1 -0
  115. package/dist/src/repo/client.d.ts.map +1 -1
  116. package/dist/src/repo/client.js +3 -0
  117. package/dist/src/repo/client.js.map +1 -1
  118. package/dist/src/repo/cluster-coordinator.d.ts +41 -4
  119. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  120. package/dist/src/repo/cluster-coordinator.js +128 -80
  121. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  122. package/dist/src/repo/coordinator-repo.d.ts +38 -1
  123. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  124. package/dist/src/repo/coordinator-repo.js +92 -13
  125. package/dist/src/repo/coordinator-repo.js.map +1 -1
  126. package/dist/src/repo/service.d.ts.map +1 -1
  127. package/dist/src/repo/service.js +7 -1
  128. package/dist/src/repo/service.js.map +1 -1
  129. package/dist/src/reputation/peer-reputation.js +1 -1
  130. package/dist/src/reputation/peer-reputation.js.map +1 -1
  131. package/dist/src/reputation/types.d.ts +3 -1
  132. package/dist/src/reputation/types.d.ts.map +1 -1
  133. package/dist/src/reputation/types.js +3 -0
  134. package/dist/src/reputation/types.js.map +1 -1
  135. package/dist/src/rn.d.ts +5 -1
  136. package/dist/src/rn.d.ts.map +1 -1
  137. package/dist/src/rn.js +7 -1
  138. package/dist/src/rn.js.map +1 -1
  139. package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
  140. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  141. package/dist/src/storage/arachnode-fret-adapter.js +15 -2
  142. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  143. package/dist/src/storage/arachnode-partition.d.ts +49 -0
  144. package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
  145. package/dist/src/storage/arachnode-partition.js +61 -0
  146. package/dist/src/storage/arachnode-partition.js.map +1 -0
  147. package/dist/src/storage/block-storage.d.ts +40 -1
  148. package/dist/src/storage/block-storage.d.ts.map +1 -1
  149. package/dist/src/storage/block-storage.js +150 -57
  150. package/dist/src/storage/block-storage.js.map +1 -1
  151. package/dist/src/storage/i-block-storage.d.ts +8 -0
  152. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/i-raw-storage.d.ts +13 -0
  154. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  155. package/dist/src/storage/kv-raw-storage.d.ts +45 -0
  156. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
  157. package/dist/src/storage/kv-raw-storage.js +113 -0
  158. package/dist/src/storage/kv-raw-storage.js.map +1 -0
  159. package/dist/src/storage/memory-storage.d.ts +20 -58
  160. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  161. package/dist/src/storage/memory-storage.js +22 -148
  162. package/dist/src/storage/memory-storage.js.map +1 -1
  163. package/dist/src/storage/memory-store-driver.d.ts +39 -0
  164. package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
  165. package/dist/src/storage/memory-store-driver.js +129 -0
  166. package/dist/src/storage/memory-store-driver.js.map +1 -0
  167. package/dist/src/storage/raw-store-codec.d.ts +10 -0
  168. package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
  169. package/dist/src/storage/raw-store-codec.js +36 -0
  170. package/dist/src/storage/raw-store-codec.js.map +1 -0
  171. package/dist/src/storage/raw-store-driver.d.ts +69 -0
  172. package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
  173. package/dist/src/storage/raw-store-driver.js +2 -0
  174. package/dist/src/storage/raw-store-driver.js.map +1 -0
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +1 -1
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
  178. package/dist/src/storage/restoration-coordinator.d.ts +73 -0
  179. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
  180. package/dist/src/storage/restoration-coordinator.js +181 -0
  181. package/dist/src/storage/restoration-coordinator.js.map +1 -0
  182. package/dist/src/storage/ring-selector.d.ts +92 -5
  183. package/dist/src/storage/ring-selector.d.ts.map +1 -1
  184. package/dist/src/storage/ring-selector.js +153 -51
  185. package/dist/src/storage/ring-selector.js.map +1 -1
  186. package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
  187. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
  188. package/dist/src/storage/ring-shift-coordinator.js +196 -0
  189. package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
  190. package/dist/src/storage/storage-monitor.d.ts +28 -0
  191. package/dist/src/storage/storage-monitor.d.ts.map +1 -1
  192. package/dist/src/storage/storage-monitor.js +42 -2
  193. package/dist/src/storage/storage-monitor.js.map +1 -1
  194. package/dist/src/storage/storage-repo.d.ts +19 -0
  195. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  196. package/dist/src/storage/storage-repo.js +172 -22
  197. package/dist/src/storage/storage-repo.js.map +1 -1
  198. package/dist/src/sync/client.d.ts.map +1 -1
  199. package/dist/src/sync/client.js +5 -1
  200. package/dist/src/sync/client.js.map +1 -1
  201. package/dist/src/sync/service.d.ts.map +1 -1
  202. package/dist/src/sync/service.js +2 -1
  203. package/dist/src/sync/service.js.map +1 -1
  204. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
  205. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  206. package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
  207. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  208. package/dist/src/testing/index.d.ts +1 -0
  209. package/dist/src/testing/index.d.ts.map +1 -1
  210. package/dist/src/testing/index.js +1 -0
  211. package/dist/src/testing/index.js.map +1 -1
  212. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  213. package/dist/src/testing/mesh-harness.js +15 -7
  214. package/dist/src/testing/mesh-harness.js.map +1 -1
  215. package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
  216. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
  217. package/dist/src/testing/raw-storage-conformance.js +313 -0
  218. package/dist/src/testing/raw-storage-conformance.js.map +1 -0
  219. package/package.json +4 -4
  220. package/src/cluster/block-transfer-service.ts +4 -3
  221. package/src/cluster/block-transfer.ts +424 -265
  222. package/src/cluster/client.ts +3 -1
  223. package/src/cluster/cluster-repo.ts +481 -71
  224. package/src/cluster/peer-key-binding.ts +37 -0
  225. package/src/cluster/quorum-restore.ts +0 -0
  226. package/src/cluster/rebalance-monitor.ts +34 -3
  227. package/src/cluster/service.ts +7 -1
  228. package/src/cluster/spread-on-churn.ts +11 -1
  229. package/src/cluster/supermajority-coupling.ts +37 -0
  230. package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
  231. package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
  232. package/src/cohort-topic/host.ts +831 -105
  233. package/src/dispute/arbitrator-selection.ts +138 -28
  234. package/src/dispute/cascade.ts +8 -1
  235. package/src/dispute/client.ts +3 -2
  236. package/src/dispute/dispute-service.ts +94 -9
  237. package/src/dispute/index.ts +7 -1
  238. package/src/dispute/invalidation.ts +40 -2
  239. package/src/dispute/service.ts +7 -1
  240. package/src/index.ts +46 -43
  241. package/src/it-utility.ts +5 -13
  242. package/src/libp2p-key-network.ts +281 -18
  243. package/src/libp2p-node-base.ts +511 -81
  244. package/src/matchmaking/query-transport.ts +9 -0
  245. package/src/network/network-manager-service.ts +8 -2
  246. package/src/owned-block-seed.ts +40 -0
  247. package/src/protocol-client.ts +10 -3
  248. package/src/protocol-limits.ts +33 -0
  249. package/src/repo/client.ts +3 -0
  250. package/src/repo/cluster-coordinator.ts +145 -82
  251. package/src/repo/coordinator-repo.ts +96 -16
  252. package/src/repo/service.ts +7 -1
  253. package/src/reputation/types.ts +3 -0
  254. package/src/rn.ts +7 -1
  255. package/src/storage/arachnode-fret-adapter.ts +33 -2
  256. package/src/storage/arachnode-partition.ts +74 -0
  257. package/src/storage/block-storage.ts +169 -61
  258. package/src/storage/i-block-storage.ts +9 -0
  259. package/src/storage/i-raw-storage.ts +14 -0
  260. package/src/storage/kv-raw-storage.ts +135 -0
  261. package/src/storage/memory-storage.ts +25 -173
  262. package/src/storage/memory-store-driver.ts +157 -0
  263. package/src/storage/raw-store-codec.ts +42 -0
  264. package/src/storage/raw-store-driver.ts +80 -0
  265. package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
  266. package/src/storage/ring-selector.ts +317 -176
  267. package/src/storage/ring-shift-coordinator.ts +271 -0
  268. package/src/storage/storage-monitor.ts +70 -3
  269. package/src/storage/storage-repo.ts +710 -558
  270. package/src/sync/client.ts +5 -1
  271. package/src/sync/service.ts +2 -1
  272. package/src/testing/cohort-topic-mesh-harness.ts +61 -11
  273. package/src/testing/index.ts +1 -0
  274. package/src/testing/mesh-harness.ts +14 -6
  275. package/src/testing/raw-storage-conformance.ts +397 -0
@@ -1,4 +1,4 @@
1
- import type { AbortOptions, Libp2p, PeerId, Stream } from "@libp2p/interface";
1
+ import type { AbortOptions, Connection, Libp2p, PeerId, Stream } from "@libp2p/interface";
2
2
  import { toString as u8ToString } from 'uint8arrays'
3
3
  import type { ClusterPeers, FindCoordinatorOptions, IKeyNetwork, IPeerNetwork } from "@optimystic/db-core";
4
4
  import { peerIdFromString } from '@libp2p/peer-id'
@@ -31,12 +31,33 @@ export const FIND_COORDINATOR_ERROR_CODES = {
31
31
  */
32
32
  SELF_COORDINATION_EXHAUSTED: 'SELF_COORDINATION_EXHAUSTED',
33
33
  /** No peer (including self) is an eligible coordinator. */
34
- NO_COORDINATOR_AVAILABLE: 'NO_COORDINATOR_AVAILABLE'
34
+ NO_COORDINATOR_AVAILABLE: 'NO_COORDINATOR_AVAILABLE',
35
+ /**
36
+ * The candidate set was non-empty but every non-self candidate serves a
37
+ * DIFFERENT network's protocol (or none of this network's). Distinct from
38
+ * NO_COORDINATOR_AVAILABLE so a Sereus-style trace points at the real cause —
39
+ * "peer(s) do not serve this network's protocol" — instead of a generic
40
+ * "all candidates excluded" / super-majority failure.
41
+ */
42
+ NO_NETWORK_COORDINATOR: 'NO_NETWORK_COORDINATOR'
35
43
  } as const;
36
44
 
37
45
  export type FindCoordinatorErrorCode =
38
46
  typeof FIND_COORDINATOR_ERROR_CODES[keyof typeof FIND_COORDINATOR_ERROR_CODES];
39
47
 
48
+ /**
49
+ * Network-membership classification of a peer relative to THIS node's network,
50
+ * derived from the peer's libp2p peerStore protocol list:
51
+ * - `serves` — advertises this network's namespaced `cluster`/`repo` protocol.
52
+ * - `foreign` — has a non-empty protocol list but none for this network → another network.
53
+ * - `unknown` — protocol list empty / peer absent → identify not yet completed. This is
54
+ * both a fresh same-network peer (will flip to `serves`) AND a cross-network
55
+ * peer (whose network-namespaced identify can NEVER complete, so it stays
56
+ * `unknown` forever) — indistinguishable at a single instant, separated over
57
+ * the retry/stabilization window.
58
+ */
59
+ export type NetworkMembership = 'serves' | 'foreign' | 'unknown';
60
+
40
61
  export class FindCoordinatorError extends Error {
41
62
  readonly code: FindCoordinatorErrorCode;
42
63
  constructor(code: FindCoordinatorErrorCode, message: string) {
@@ -94,7 +115,16 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
94
115
  selfCoordinationConfig?: SelfCoordinationConfig,
95
116
  networkMode?: NetworkMode,
96
117
  persistence?: NetworkStatePersistence,
97
- private readonly reputation?: IPeerReputation
118
+ private readonly reputation?: IPeerReputation,
119
+ /**
120
+ * Network-namespaced protocol prefix (`/optimystic/<networkName>`). When
121
+ * provided, coordinator/cohort selection is scoped to peers that serve THIS
122
+ * network's `cluster`/`repo` protocol, so a peer that only belongs to another
123
+ * network sharing the same physical nodes/bootstraps is never chosen. When
124
+ * ABSENT, the membership filter is disabled (today's exact behavior) — required
125
+ * for backward compatibility because most call sites don't know the network name.
126
+ */
127
+ private readonly protocolPrefix?: string
98
128
  ) {
99
129
  this.selfCoordinationConfig = {
100
130
  gracePeriodMs: selfCoordinationConfig?.gracePeriodMs ?? 30_000,
@@ -291,17 +321,39 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
291
321
  return undefined
292
322
  }
293
323
 
324
+ /**
325
+ * True for a circuit-relay ("limited") connection. libp2p stamps a relayed
326
+ * connection with `limits` (per-circuit data/duration caps); we additionally
327
+ * sniff the multiaddr for `/p2p-circuit` as a fallback for transports/versions
328
+ * that don't populate `limits`.
329
+ */
330
+ private isLimitedConnection(c: Connection): boolean {
331
+ if ((c as { limits?: unknown }).limits != null) return true
332
+ const addr = c.remoteAddr?.toString?.()
333
+ return addr != null && addr.includes('/p2p-circuit')
334
+ }
335
+
294
336
  connect(peerId: PeerId, protocol: string, options?: AbortOptions): Promise<Stream> {
295
337
  const conns = this.libp2p.getConnections?.(peerId) ?? []
296
338
  // Filter to only-open connections so a closing/closed entry that libp2p
297
339
  // hasn't yet evicted from its index doesn't get picked up here.
298
- const open = conns.find(c => c?.status === 'open' && typeof c?.newStream === 'function')
299
- if (open) {
340
+ const open = conns.filter(c => c?.status === 'open' && typeof c?.newStream === 'function')
341
+ // Prefer a DIRECT connection over a limited (circuit-relay) one for the RPC.
342
+ // A relayed/limited connection can be reset by the relay once a per-circuit
343
+ // cap or reservation lapses (@libp2p/circuit-relay-v2), surfacing to the
344
+ // coordinator as a StreamResetError that fails consensus. After DCUtR upgrades
345
+ // a relayed link to direct, both connections briefly coexist — picking the
346
+ // direct one avoids riding the soon-to-be-reset circuit. We only fall back to
347
+ // the limited connection (with runOnLimitedConnection) when it is the only open
348
+ // path — the steady state for browsers and NATed peers before any upgrade.
349
+ const chosen = open.find(c => !this.isLimitedConnection(c)) ?? open[0]
350
+ if (chosen) {
300
351
  // runOnLimitedConnection: true is required to open a stream over a
301
352
  // circuit-relay (limited) connection — the steady-state path for
302
353
  // browsers and NATed peers. Without it, the warm relay connection
303
- // from a prior dialProtocol cannot be reused on subsequent RPCs.
304
- return open.newStream([protocol], {
354
+ // from a prior dialProtocol cannot be reused on subsequent RPCs. It is
355
+ // a harmless no-op on the preferred direct connection.
356
+ return chosen.newStream([protocol], {
305
357
  signal: options?.signal,
306
358
  runOnLimitedConnection: true,
307
359
  negotiateFully: false
@@ -332,6 +384,12 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
332
384
  const t0 = Date.now();
333
385
  const excludedSet = new Set<string>((_options?.excludedPeers ?? []).map(p => p.toString()))
334
386
  const keyStr = this.toCacheKey(key).substring(0, 12);
387
+ // Tracks whether the network-membership filter excluded an UNCONFIRMED candidate
388
+ // — `foreign` (another network) OR `unknown` (not yet confirmed to serve this
389
+ // network) — during any attempt. If selection ultimately fails with self
390
+ // unavailable, this lets us surface NO_NETWORK_COORDINATOR (the real cause)
391
+ // instead of the generic NO_COORDINATOR_AVAILABLE.
392
+ let droppedUnconfirmedAnyAttempt = false;
335
393
 
336
394
  this.log('findCoordinator:start key=%s excluded=%o', keyStr, Array.from(excludedSet).map(s => s.substring(0, 12)))
337
395
 
@@ -367,7 +425,17 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
367
425
  .sort((a, b) => (this.reputation?.getScore(a) ?? 0) - (this.reputation?.getScore(b) ?? 0))
368
426
  this.log('findCoordinator:fret-connected key=%s count=%d peers=%o', keyStr, connectedFretIds.length, connectedFretIds.map(s => s.substring(0, 12)))
369
427
 
370
- const pick = connectedFretIds[0]
428
+ // Network-membership scoping (no-op when protocolPrefix is unset): only a peer
429
+ // CONFIRMED to serve this network ('serves') is eligible — both `foreign`
430
+ // (another network) and `unknown` (not yet identified) peers are excluded
431
+ // from selection. A cross-network peer is permanently 'unknown' (its
432
+ // namespaced identify never completes), so it is never gambled on; over the
433
+ // 3×500ms retry window a genuine same-network peer flips to 'serves' on a
434
+ // re-read of the peerStore and is selected normally on that attempt. Self
435
+ // always classifies as 'serves' and stays eligible.
436
+ const { ranked, droppedUnconfirmed } = await this.filterByMembership(connectedFretIds)
437
+ if (droppedUnconfirmed) droppedUnconfirmedAnyAttempt = true
438
+ const pick = ranked[0]
371
439
  if (pick) {
372
440
  const pid = peerIdFromString(pick)
373
441
  this.recordCoordinator(key, pid)
@@ -378,15 +446,23 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
378
446
  this.log('findCoordinator getNeighborIdsForKey failed - %o', err)
379
447
  }
380
448
 
381
- // fallback: prefer any existing connected peer that's not excluded or banned
382
- const connectedPick = connected
449
+ // fallback: prefer any existing connected peer that's not excluded or banned,
450
+ // scoped to this network's serving peers (a `foreign` or not-yet-confirmed
451
+ // `unknown` peer is never picked). Note this candidate set is built from
452
+ // connected REMOTE peers and never includes self, so when no serving peer is
453
+ // present selection falls through to the last-resort self-coordination block.
454
+ const connectedCandidates = connected
383
455
  .filter(p => !excludedSet.has(p.toString()) && !(this.reputation?.isBanned(p.toString())))
384
456
  .sort((a, b) => (this.reputation?.getScore(a.toString()) ?? 0) - (this.reputation?.getScore(b.toString()) ?? 0))
385
- [0]
457
+ .map(p => p.toString())
458
+ const { ranked: connRanked, droppedUnconfirmed: connDroppedUnconfirmed } = await this.filterByMembership(connectedCandidates)
459
+ if (connDroppedUnconfirmed) droppedUnconfirmedAnyAttempt = true
460
+ const connectedPick = connRanked[0]
386
461
  if (connectedPick) {
387
- this.recordCoordinator(key, connectedPick)
462
+ const pid = peerIdFromString(connectedPick)
463
+ this.recordCoordinator(key, pid)
388
464
  this.log('findCoordinator:done key=%s ms=%d source=%s', keyStr, Date.now() - t0, 'connected-fallback')
389
- return connectedPick
465
+ return pid
390
466
  }
391
467
 
392
468
  // If no connections and not the last attempt, wait and retry
@@ -424,6 +500,20 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
424
500
  return self
425
501
  }
426
502
 
503
+ // Self is excluded and selection found no eligible peer. If the membership filter is
504
+ // the reason the candidate set emptied (the only other peers are `foreign` — serving
505
+ // a DIFFERENT network — or `unknown` — not yet confirmed to serve this network),
506
+ // surface a distinct, accurate cause instead of the generic codes below.
507
+ if (droppedUnconfirmedAnyAttempt) {
508
+ this.log('findCoordinator:no-network-coordinator key=%s prefix=%s self=%s',
509
+ keyStr, this.protocolPrefix ?? '?', self.toString().substring(0, 12))
510
+ throw new FindCoordinatorError(
511
+ FIND_COORDINATOR_ERROR_CODES.NO_NETWORK_COORDINATOR,
512
+ `No coordinator available for key on network ${this.protocolPrefix ?? '?'}: ` +
513
+ `the remaining candidate peer(s) are foreign or not-yet-confirmed to serve this network's cluster/repo protocol.`
514
+ );
515
+ }
516
+
427
517
  // Self is excluded. On a solo/bootstrap node (HWM<=1 and no other connected/FRET peers),
428
518
  // this means the caller already tried self and the retry has nowhere to go — surface a
429
519
  // distinct error so retry logic stops and the original first-attempt cause is preserved.
@@ -458,7 +548,7 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
458
548
  private parseMultiaddrs(addrs: string[]): string[] {
459
549
  const out: string[] = []
460
550
  for (const a of addrs) {
461
- try { multiaddr(a); out.push(a) } catch (err) { console.warn('invalid multiaddr from connection', a, err) }
551
+ try { multiaddr(a); out.push(a) } catch (err) { this.log('WARN: invalid multiaddr from connection %s %o', a, err) }
462
552
  }
463
553
  return out
464
554
  }
@@ -467,13 +557,70 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
467
557
  const t0 = Date.now();
468
558
  const fret = this.getFret()
469
559
  const coord = await hashKey(key)
470
- const cohort = fret.assembleCohort(coord, this.clusterSize)
560
+ // When membership scoping is active, over-fetch a wider proximity band so the
561
+ // nearest peers that SERVE this network are in the candidate pool even if cross-
562
+ // network peers sit nearer the key (see membershipOverfetch).
563
+ const wants = this.protocolPrefix != null ? this.membershipOverfetch() : this.clusterSize
564
+ const cohort = fret.assembleCohort(coord, wants)
471
565
  const keyStr = this.toCacheKey(key).substring(0, 12);
472
566
  this.log('findCluster:start key=%s', keyStr);
473
567
 
474
568
  // Include self in the cohort
475
569
  const selfId = this.libp2p.peerId.toString()
476
- const ids = Array.from(new Set([...cohort, selfId]))
570
+ let ids = Array.from(new Set([...cohort, selfId]))
571
+
572
+ // Network-membership scoping (no-op when protocolPrefix is unset): a cohort
573
+ // member that serves a DIFFERENT network's protocol can never negotiate THIS
574
+ // network's cluster/repo dial, so it guarantees a super-majority failure rather
575
+ // than contributing a promise. Drop such 'foreign' members; build the cohort from
576
+ // positively-'serves' members only and NEVER admit a not-yet-identified ('unknown')
577
+ // member. A permanently cross-network peer and a freshly-discovered same-network
578
+ // peer mid-identify are indistinguishable while 'unknown' (both have an empty
579
+ // peerStore protocol list), so admitting an 'unknown' on the strength of a viability
580
+ // floor risks pulling a cross-network contaminant into the cohort — its repo dial
581
+ // then negotiates a different network's protocol and the whole write fails. A fresh
582
+ // same-network peer is not starved: it flips to 'serves' once identify completes and
583
+ // is re-included on the caller's retry, and in the meantime a self-only cohort still
584
+ // completes the write under allowClusterDownsize (the default).
585
+ // Scoped path only: one peerStore read per cohort member yields both protocols
586
+ // (for membership classification here) and addresses (reused at backfill below),
587
+ // so a finally-selected member isn't fetched from the peerStore twice. Left
588
+ // undefined on the unscoped path, which never classifies membership.
589
+ let peerStoreRecords: Record<string, { protocols: string[]; addrs: string[] }> | undefined
590
+ if (this.protocolPrefix != null) {
591
+ // `cohort` is the over-fetched nearest-first band. Classify each non-self
592
+ // member, preserving proximity order within each tier.
593
+ const nonSelf = cohort.filter(id => id !== selfId)
594
+ peerStoreRecords = await this.getPeerStoreRecordsByPeer(nonSelf)
595
+ const serves: string[] = []
596
+ const unknown: string[] = []
597
+ let foreignDropped = 0
598
+ for (const id of nonSelf) {
599
+ const m = this.membershipOf(id, peerStoreRecords[id]?.protocols)
600
+ if (m === 'serves') serves.push(id)
601
+ else if (m === 'unknown') unknown.push(id)
602
+ else foreignDropped++
603
+ }
604
+ // Take the nearest `clusterSize - 1` SERVING peers. Self is ALWAYS added below and
605
+ // counts toward `clusterSize` (matching the unscoped path, where `assembleCohort`
606
+ // returns the nearest `clusterSize` peers INCLUDING self when self is near the key —
607
+ // the coordinator case), so reserving a slot for self keeps a healthy same-network
608
+ // cohort at exactly `clusterSize` members rather than `clusterSize + 1`. Over-sizing
609
+ // would inflate the super-majority promise count (ceil(peerCount * threshold)) above
610
+ // what the configured `clusterSize` intends and hurt write availability. 'unknown'
611
+ // members are never backfilled: an 'unknown' peer may be a permanently cross-network
612
+ // contaminant whose repo dial cannot negotiate this network's protocol, and a fresh
613
+ // same-network peer mid-identify is indistinguishable from it. We therefore admit
614
+ // only positively-'serves' peers; when self is the sole serving member the cohort is
615
+ // self-only, which completes the write under allowClusterDownsize (the default) and
616
+ // re-includes any legitimate peer as 'serves' on the caller's retry once identify
617
+ // completes. `unknown.length` is still computed above for the diagnostic log line.
618
+ const nonSelfTarget = Math.max(0, this.clusterSize - 1)
619
+ const others = serves.slice(0, nonSelfTarget)
620
+ ids = Array.from(new Set([selfId, ...others]))
621
+ this.log('findCluster:membership key=%s serves=%d unknown=%d foreignDropped=%d kept=%d',
622
+ keyStr, serves.length, unknown.length, foreignDropped, ids.length)
623
+ }
477
624
 
478
625
  const connectedByPeer = this.getConnectedAddrsByPeer()
479
626
  const connectedPeerIds = Object.keys(connectedByPeer)
@@ -481,8 +628,17 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
481
628
  // Backfill addresses from the peerStore for cohort members we don't have
482
629
  // a live connection to. The cohort is keyspace-determined and can include
483
630
  // peers we know-of but haven't dialed yet; without this backfill those
484
- // would be silently dropped.
485
- const peerStoreAddrs = await this.getPeerStoreAddrsByPeer(ids.filter(id => id !== selfId))
631
+ // would be silently dropped. On the scoped path reuse the addresses already
632
+ // read into `peerStoreRecords` above (no second store.get per member); on the
633
+ // unscoped path (no record map) do the single peerStore read as before.
634
+ const backfillIds = ids.filter(id => id !== selfId)
635
+ const peerStoreAddrs = peerStoreRecords
636
+ ? Object.fromEntries(
637
+ backfillIds
638
+ .map(id => [id, peerStoreRecords![id]?.addrs ?? []] as const)
639
+ .filter(([, addrs]) => addrs.length > 0)
640
+ )
641
+ : await this.getPeerStoreAddrsByPeer(backfillIds)
486
642
 
487
643
  this.log('findCluster key=%s fretCohort=%d connected=%d', keyStr, cohort.length, connectedPeerIds.length)
488
644
  if (verbose) this.log('findCluster:detail key=%s cohortPeers=%o connectedPeers=%o', keyStr, ids, connectedPeerIds)
@@ -541,4 +697,111 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
541
697
  }))
542
698
  return out
543
699
  }
700
+
701
+ /**
702
+ * Single-pass peerStore read returning BOTH protocols and addresses per peer from one
703
+ * `store.get` call. Used on the membership-scoped `findCluster` hot path, where the
704
+ * cohort needs protocols (to classify membership) AND addresses (to backfill dial
705
+ * targets) for the same peers — reading them together avoids a second `store.get` per
706
+ * finally-selected member. Same error handling as {@link getPeerStoreProtocolsByPeer}
707
+ * and {@link getPeerStoreAddrsByPeer}: a missing peer or peerStore failure is left
708
+ * absent from the map (caller treats absent protocols as 'unknown', absent addrs as none).
709
+ */
710
+ private async getPeerStoreRecordsByPeer(ids: string[]): Promise<Record<string, { protocols: string[]; addrs: string[] }>> {
711
+ const out: Record<string, { protocols: string[]; addrs: string[] }> = {}
712
+ const store = (this.libp2p as { peerStore?: { get?: (id: PeerId) => Promise<{ protocols?: string[]; addresses?: Array<{ multiaddr: { toString(): string } }> }> } }).peerStore
713
+ if (!store?.get) return out
714
+ await Promise.all(ids.map(async (idStr) => {
715
+ try {
716
+ const pid = peerIdFromString(idStr)
717
+ const peer = await store.get!(pid)
718
+ const addrs = (peer?.addresses ?? []).map(a => a.multiaddr.toString())
719
+ out[idStr] = { protocols: peer?.protocols ?? [], addrs }
720
+ } catch {
721
+ // Unknown peer or peerStore failure — leave out of the map.
722
+ }
723
+ }))
724
+ return out
725
+ }
726
+
727
+ /**
728
+ * Prefetch each peer's advertised protocol list from the libp2p peerStore.
729
+ * Returns a map from peer-id string to its protocols (empty array when the peer
730
+ * is absent or has not yet been identified). Mirrors {@link getPeerStoreAddrsByPeer};
731
+ * errors are swallowed so a peerStore hiccup degrades to "unknown" rather than throwing.
732
+ */
733
+ private async getPeerStoreProtocolsByPeer(ids: string[]): Promise<Record<string, string[]>> {
734
+ const out: Record<string, string[]> = {}
735
+ const store = (this.libp2p as { peerStore?: { get?: (id: PeerId) => Promise<{ protocols?: string[] }> } }).peerStore
736
+ if (!store?.get) return out
737
+ await Promise.all(ids.map(async (idStr) => {
738
+ try {
739
+ const pid = peerIdFromString(idStr)
740
+ const peer = await store.get!(pid)
741
+ out[idStr] = peer?.protocols ?? []
742
+ } catch {
743
+ // Unknown peer or peerStore failure — leave out (treated as 'unknown').
744
+ }
745
+ }))
746
+ return out
747
+ }
748
+
749
+ /**
750
+ * Over-fetch width for network-membership scoping. A cross-network peer can sit
751
+ * NEARER the key than a legitimate same-network peer and displace it from the
752
+ * nearest-`clusterSize` window, so when scoping is active we ask FRET for a wider
753
+ * proximity band and then keep the nearest peers that actually serve this network.
754
+ * (A ring polluted by more cross-network peers than this band is the domain of the
755
+ * separate FRET-side eviction follow-up; this band covers realistic co-location.)
756
+ */
757
+ private membershipOverfetch(): number {
758
+ return Math.max(this.clusterSize * 4, this.clusterSize + 16)
759
+ }
760
+
761
+ /**
762
+ * Classify a peer's network membership from its advertised protocols. Self always
763
+ * `serves` (it trivially serves its own network). When no `protocolPrefix` is
764
+ * configured the filter is disabled and EVERY peer is reported `serves`, so all
765
+ * callers behave exactly as before this scoping was added.
766
+ */
767
+ private membershipOf(idStr: string, protocols: string[] | undefined): NetworkMembership {
768
+ if (this.protocolPrefix == null) return 'serves'
769
+ if (idStr === this.libp2p.peerId.toString()) return 'serves'
770
+ if (protocols == null || protocols.length === 0) return 'unknown'
771
+ if (protocols.includes(`${this.protocolPrefix}/cluster/1.0.0`)
772
+ || protocols.includes(`${this.protocolPrefix}/repo/1.0.0`)) return 'serves'
773
+ return 'foreign'
774
+ }
775
+
776
+ /**
777
+ * Scope a reputation-ordered candidate id list to this network for COORDINATOR
778
+ * selection: keep ONLY peers confirmed to serve this network (`serves`, which always
779
+ * includes self), dropping both `foreign` peers (serving another network) and
780
+ * `unknown` peers (peerStore protocol list empty — not yet confirmed). Incoming
781
+ * (reputation) order is preserved among the surviving `serves` peers. A no-op
782
+ * (returns the input unchanged, no drops) when `protocolPrefix` is unset or the list
783
+ * is empty — the membership-disabled path is therefore untouched.
784
+ *
785
+ * `droppedUnconfirmed` reports whether any candidate was excluded because it was not
786
+ * confirmed to serve this network — `foreign` OR `unknown` under scoping — so the
787
+ * caller can surface a distinct "no network coordinator" failure rather than a generic
788
+ * one. An `unknown` peer is not gambled on as coordinator: a permanent cross-network
789
+ * contaminant and a fresh same-network peer mid-identify are indistinguishable at an
790
+ * instant, but the filter re-reads the peerStore on every retry attempt, so a genuine
791
+ * same-network peer that completes `identify` within the retry window flips to `serves`
792
+ * and is selected normally on that attempt.
793
+ */
794
+ private async filterByMembership(ids: string[]): Promise<{ ranked: string[]; droppedUnconfirmed: boolean }> {
795
+ if (this.protocolPrefix == null || ids.length === 0) return { ranked: ids, droppedUnconfirmed: false }
796
+ const selfStr = this.libp2p.peerId.toString()
797
+ const protocolsByPeer = await this.getPeerStoreProtocolsByPeer(ids.filter(id => id !== selfStr))
798
+ const serves: string[] = []
799
+ let droppedUnconfirmed = false
800
+ for (const id of ids) {
801
+ const m = this.membershipOf(id, protocolsByPeer[id])
802
+ if (m === 'serves') serves.push(id)
803
+ else droppedUnconfirmed = true
804
+ }
805
+ return { ranked: serves, droppedUnconfirmed }
806
+ }
544
807
  }