@optimystic/db-p2p 1.0.0-beta.3 → 1.0.0

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 (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +126 -6
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +378 -50
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +234 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +6 -1
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +77 -5
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +131 -20
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +238 -122
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +625 -472
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +122 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +245 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +77 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +226 -100
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +422 -56
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1260
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +6 -1
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +145 -19
  217. package/src/repo/coordinator-repo.ts +652 -505
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +280 -72
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +293 -112
@@ -18,10 +18,13 @@ import { BlockStorage } from './storage/block-storage.js';
18
18
  import { MemoryRawStorage } from './storage/memory-storage.js';
19
19
  import { withReadCache, type ResolvedReadCache } from './storage/with-read-cache.js';
20
20
  import type { IRawStorage } from './storage/i-raw-storage.js';
21
+ import type { IKVStore } from './storage/i-kv-store.js';
22
+ import { MemoryKVStore } from './storage/memory-kv-store.js';
23
+ import { KvUnderReplicationLedger } from './repo/kv-under-replication-ledger.js';
21
24
  import { latestClaimFromArchive, servableProof, type ArchiveServingRepo } from './storage/block-archive.js';
22
25
  import { createServedRepoProxy } from './repo/served-repo-proxy.js';
23
26
  import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
24
- import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
27
+ import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type CommittedHoldersSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
25
28
  import { createReconcileBlock } from './cluster/reconcile-block.js';
26
29
  import { resolveClusterPolicy, type ClusterPolicyOptions } from './cluster/cluster-policy.js';
27
30
  import { assertClusterSizeCoupling } from './cluster/cluster-size-coupling.js';
@@ -34,8 +37,11 @@ import { ClusterClient } from './cluster/client.js';
34
37
  import type { IRepo, ICluster, ITransactionValidator, BlockId, IBlockChangeNotifier } from '@optimystic/db-core';
35
38
  import type { ITransactionStateStore } from './cluster/i-transaction-state-store.js';
36
39
  import { networkManagerService, type NetworkManagerService } from './network/network-manager-service.js';
40
+ import { assertCircuitRelayTransport, assertRelayAddrsAdvertisable, planRelayListenAddrs, superviseRelayReservations } from './network/relay-reservation.js';
37
41
  import type { SpreadOnChurnConfig, SpreadOnChurnMonitor } from './cluster/spread-on-churn.js';
38
42
  import { BlockTransferCoordinator } from './cluster/block-transfer.js';
43
+ import { pushBlockToPeers } from './cluster/block-transfer-service.js';
44
+ import { UnderReplicationDrain, type UnderReplicationDrainConfig } from './repo/under-replication-drain.js';
39
45
  import type { RebalanceMonitorConfig } from './cluster/rebalance-monitor.js';
40
46
  import { fretService, Libp2pFretService } from 'p2p-fret';
41
47
  import { syncService } from './sync/service.js';
@@ -85,6 +91,7 @@ import {
85
91
  type PushStateInit,
86
92
  type NotificationVerifier,
87
93
  } from '@optimystic/db-core';
94
+ import { routingKeyForBlock } from '@optimystic/db-core';
88
95
  import { PartitionDetector } from './cluster/partition-detector.js';
89
96
  import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js';
90
97
  import { createLogger } from './logger.js';
@@ -154,6 +161,9 @@ const wiringLog = createLogger('node-wiring');
154
161
  */
155
162
  export type RawStorageProvider = IRawStorage | (() => IRawStorage);
156
163
 
164
+ /** Factory function or instance for the node's durable key-value store; see {@link NodeOptions.kvStore}. */
165
+ export type KVStoreProvider = IKVStore | (() => IKVStore);
166
+
157
167
  /**
158
168
  * `ClusterPolicyOptions` is intersected in, not restated: `resolveClusterPolicy` consumes those
159
169
  * fields structurally, so a second copy of the shape here would let a newly added knob compile and
@@ -198,7 +208,22 @@ export type NodeOptions = ClusterPolicyOptions & {
198
208
  relayServerInit?: CircuitRelayServerInit;
199
209
  /** Storage provider - either an IRawStorage instance or a factory function. Defaults to MemoryRawStorage if not provided. See {@link RawStorageProvider} for the ownership rule. */
200
210
  storage?: RawStorageProvider;
201
- /** Override libp2p listen multiaddrs. */
211
+ /**
212
+ * Durable key-value store for node-local state that must survive a restart — today, the
213
+ * under-replication ledger (which blocks this node acknowledged below full replication, and who
214
+ * is still missing each). An instance or a factory, the same shape as {@link NodeOptions.storage}.
215
+ * Absent → an in-memory store, with a `node-wiring` warning: the ledger still works, but what it
216
+ * records is lost on restart. `FileKVStore` from `@optimystic/db-p2p-storage-fs` may share
217
+ * `FileRawStorage`'s base path; see its README.
218
+ */
219
+ kvStore?: KVStoreProvider;
220
+ /**
221
+ * Override libp2p listen multiaddrs. An entry naming a relay (`<relay address>/p2p/<relay id>/p2p-circuit`)
222
+ * is not handed to libp2p as written: the node listens on a bare `/p2p-circuit` and keeps the
223
+ * reservation on that relay itself (`superviseRelayReservation` in `network/relay-reservation.ts`).
224
+ * Such an entry needs a circuit-relay transport and cannot be combined with {@link NodeOptions.announceAddrs},
225
+ * which would hide the circuit address; both are rejected before anything starts.
226
+ */
202
227
  listenAddrs?: string[];
203
228
  /**
204
229
  * Multiaddrs to advertise INSTEAD OF the listen addrs. For a node behind a NAT / reverse proxy /
@@ -253,6 +278,18 @@ export type NodeOptions = ClusterPolicyOptions & {
253
278
  */
254
279
  rebalance?: Partial<RebalanceMonitorConfig> & { enabled?: boolean };
255
280
 
281
+ /**
282
+ * Under-replication drain tuning. The drain sends the copies the under-replication ledger says
283
+ * this node still owes — blocks it acknowledged below full replication — to the missing members
284
+ * as they become reachable, and fires `onBlockDurabilityReached` on the node's `storageRepo` when
285
+ * a block finally has every copy. It needs only the key network and the node's own storage, so it
286
+ * runs whether or not arachnode/FRET rebalancing is enabled. Absent -> enabled with defaults (see
287
+ * `UnderReplicationDrainConfig`). Set { enabled: false } to leave the ledger unsent on this node.
288
+ * The push deadlines are separate because they bound the wire, not the pass: `pushDialTimeoutMs`
289
+ * (default 3000) and `pushResponseTimeoutMs` (default 10000), the same caps spread-on-churn uses.
290
+ */
291
+ underReplicationDrain?: Partial<UnderReplicationDrainConfig> & { enabled?: boolean; pushDialTimeoutMs?: number; pushResponseTimeoutMs?: number };
292
+
256
293
  /** Transaction validator for cluster consensus */
257
294
  validator?: ITransactionValidator;
258
295
 
@@ -394,6 +431,24 @@ function resolveStorage(provider: RawStorageProvider | undefined, networkName: s
394
431
  return withReadCache(storage, `node:${networkName}`);
395
432
  }
396
433
 
434
+ /**
435
+ * Resolve the node's durable key-value store, beside {@link resolveStorage}. The memory fallback is
436
+ * LOUD on purpose: a silent one would look like the under-replication ledger works and then fail the
437
+ * one case it exists for — a shortfall recorded before a restart being there after it.
438
+ *
439
+ * NOTE: with a key-value store always present, `PersistentTransactionStateStore` is now
440
+ * constructible here from the same instance. Wiring it changes commit-retry recovery behaviour and
441
+ * belongs to `feat-long-lived-pend-completes-as-members-appear`, not to this resolver;
442
+ * `NodeOptions.transactionStateStore` stays the only way it is supplied.
443
+ */
444
+ function resolveKvStore(provider: KVStoreProvider | undefined): IKVStore {
445
+ if (provider !== undefined) {
446
+ return typeof provider === 'function' ? provider() : provider;
447
+ }
448
+ wiringLog('WARN: no kvStore supplied — using an in-memory store. The under-replication ledger (blocks this node acknowledged before every cohort member held them) will NOT survive a restart, so copies owed at shutdown are never sent. Supply NodeOptions.kvStore (e.g. FileKVStore) to keep it.');
449
+ return new MemoryKVStore();
450
+ }
451
+
397
452
  /**
398
453
  * Resolve the full FRET engine the cohort-topic host needs.
399
454
  *
@@ -435,6 +490,8 @@ export async function createLibp2pNodeBase(
435
490
  }
436
491
  ): Promise<OptimysticNode> {
437
492
  const { storage: rawStorage, lease } = resolveStorage(options.storage, options.networkName);
493
+ const kvStore = resolveKvStore(options.kvStore);
494
+ const underReplicationLedger = new KvUnderReplicationLedger(kvStore);
438
495
 
439
496
  // Create placeholder restore callback (will be replaced after node starts)
440
497
  let restoreCallback: RestoreCallback = async (_blockId, _rev?) => {
@@ -487,7 +544,26 @@ export async function createLibp2pNodeBase(
487
544
 
488
545
  const nodePrivateKey = options.privateKey ?? await generateKeyPair('Ed25519');
489
546
 
490
- const listenAddrs = options.listenAddrs ?? defaults.listenAddrs;
547
+ // A listen address naming a relay (`<relay>/p2p/<id>/p2p-circuit`, the only shape a phone or browser
548
+ // can take) is handed to libp2p as a bare `/p2p-circuit` and the relay is supervised by this node
549
+ // instead, so the reservation survives relay restarts, dropped relay connections and libp2p's own
550
+ // renewal. See `network/relay-reservation.ts` for why the configured shape cannot recover.
551
+ // NOTE: accepted tradeoff — a bare `/p2p-circuit` listener also turns on libp2p's relay discovery,
552
+ // whose topology handler can reserve on ANY connected peer serving the relay hop protocol (every
553
+ // Optimystic node started with `relay: true` does), and that reservation takes the pending slot
554
+ // meant for the named relay. The node then stays reachable, but through that other relay, so a
555
+ // partner that enrolled its circuit address through the named relay cannot dial it until that
556
+ // reservation drops; the supervisor reports it as `relay-reservation:slot-taken` and retries at its
557
+ // backoff cap. It happens only when a relay-only node is directly connected to a second relay server,
558
+ // and Sereus runs with the same behaviour. Revisit on a deployment that connects relay-only nodes to
559
+ // more than one relay server, or on the libp2p upgrade below.
560
+ // NOTE: circuit-relay-v2 >= 4.2 (needs @libp2p/interface ^3.3) refreshes reservations in place and
561
+ // re-applies a configured reservation for its own relay; when the libp2p line moves, revisit keeping
562
+ // the host's configured address and re-requesting with 'configured', which drops the rewrite and the
563
+ // discovery side effect.
564
+ const relayPlan = planRelayListenAddrs(options.listenAddrs ?? defaults.listenAddrs);
565
+ assertRelayAddrsAdvertisable(relayPlan.supervisedRelays, options.announceAddrs);
566
+ const listenAddrs = relayPlan.listenAddrs;
491
567
  const transports = options.transports ?? defaults.transports;
492
568
 
493
569
  // --- cohort-topic substrate activation (opt-in; default off → today's bare behavior, zero cost) ---
@@ -544,13 +620,14 @@ export async function createLibp2pNodeBase(
544
620
  transports,
545
621
  connectionEncrypters: [noise()],
546
622
  streamMuxers: [yamux()],
547
- // Narrow cast confined to the `services` field: the built-in factories (identify/dcutr/…) are
548
- // typed against a SECOND copy of `@libp2p/interface` pulled in transitively (via `@libp2p/crypto`),
549
- // whose `Uint8Array<ArrayBuffer>` vs `<ArrayBufferLike>` PeerId/key shapes are structurally
550
- // incompatible with the top-level copy a dependency-dedup artifact, not a real mismatch. The cast
551
- // stays on this field alone so the rest of `libp2pOptions` remains fully typed as `Libp2pInit`.
552
- // NOTE: this cast exists ONLY because of the duplicate @libp2p/interface install; if that dedups
553
- // (or on a libp2p bump) drop `as unknown as NonNullable<Libp2pInit['services']>` and type the map directly.
623
+ // Narrow cast confined to the `services` field, so the rest of `libp2pOptions` stays typed as
624
+ // `Libp2pInit`: `@libp2p/dcutr` and `@libp2p/autonat` are typed against `@libp2p/interface` 3.2.x
625
+ // while `libp2p` resolves 3.1.x, and the two minors disagree on `Uint8Array<ArrayBuffer>` vs
626
+ // `<ArrayBufferLike>` a type-level artifact of the minor split, not a runtime mismatch.
627
+ // NOTE: accepted tradeoff raising this package's `@libp2p/interface` pin to ^3.2 to drop the cast
628
+ // was declined: the 3.1/3.2 split is deliberate (scripts/shared-majors.cjs), and `libp2p` and its
629
+ // core services resolve 3.1.0 regardless, so the cast would only move. Revisit when that libp2p
630
+ // release line itself declares `@libp2p/interface@^3.2` or later.
554
631
  services: ({
555
632
  // `@libp2p/identify` is the ONE service here whose protocol id it builds itself:
556
633
  // `Identify`/`IdentifyPush` both emit `/${protocolPrefix}/id[/push]/1.0.0`, always
@@ -643,15 +720,14 @@ export async function createLibp2pNodeBase(
643
720
  responsibilityK: options.responsibilityK ?? 1,
644
721
  ...inboundAuthorization
645
722
  });
646
- // RepoService.checkRedirect needs the running node (network manager for the
647
- // responsible-set computation, self id for the membership check, connection
648
- // addrs for redirect targets). The libp2p components.libp2p proxy does NOT
649
- // reliably resolve from inside a service at request time, so the node is
650
- // injected explicitly post-construction via setLibp2p(node) below — the same
651
- // mechanism networkManager/fret use — rather than forwarded here. checkRedirect
652
- // keys the responsible set on the RAW encoded block id
653
- // (getCluster(encode(blockKey)) hashKey(encode(...))), matching the
654
- // coordinator's findCluster(encode(blockId)) — same cohort, no spurious redirect.
723
+ // RepoService.checkRedirect needs the running node (its `keyNetwork` attachment for
724
+ // the responsible set, self id for the membership check, connection addrs for
725
+ // redirect targets). The libp2p components.libp2p proxy does NOT reliably resolve
726
+ // from inside a service at request time, so the node is injected explicitly
727
+ // post-construction via setLibp2p(node) below — the same mechanism networkManager/fret
728
+ // use — rather than forwarded here. checkRedirect asks the node's own key network for
729
+ // the block's routing key (`routingKeyForBlock`), the same call the writer and the
730
+ // coordinator make — one rule, so a correctly routed request is never redirected.
655
731
  return serviceFactory({
656
732
  registrar: components.registrar,
657
733
  repo: repoProxy
@@ -712,6 +788,10 @@ export async function createLibp2pNodeBase(
712
788
  capacity: 2048,
713
789
  profile: options.fretProfile ?? ((options.bootstrapNodes?.length ?? 0) > 0 ? 'core' : 'edge'),
714
790
  networkName: options.networkName,
791
+ // NOTE: FRET's `seedFromBootstraps` seeds the FIRST `/p2p/` id of each entry, so a partner's circuit
792
+ // address (`<relay>/p2p/<relay>/p2p-circuit/p2p/<partner>`) seeds the relay, not the partner. Harmless
793
+ // today: the partner is learned once its connection opens, and the relay is classified foreign. If a
794
+ // phone ever fails to find a partner it was given only as a circuit bootstrap, fix FRET to take the last id.
715
795
  bootstraps: options.bootstrapNodes ?? []
716
796
  });
717
797
  const svc = svcFactory(components) as Libp2pFretService;
@@ -768,12 +848,18 @@ export async function createLibp2pNodeBase(
768
848
  const wired = node.services as unknown as WiredServices;
769
849
  wired.fret.setLibp2p(node);
770
850
  wired.networkManager.setLibp2p(node);
771
- // RepoService.checkRedirect resolves the network manager / self id / connection
851
+ // RepoService.checkRedirect resolves the key network / self id / connection
772
852
  // addrs through this injected node (the components.libp2p proxy is unreliable
773
853
  // from inside a service at request time). Done before start() so the protocol
774
- // handler is live with a resolvable node from its first request.
854
+ // handler is live with a resolvable node from its first request; the `keyNetwork`
855
+ // attachment itself arrives after start(), and until then checkRedirect handles locally.
775
856
  wired.repo.setLibp2p(node);
776
857
 
858
+ // A relay-naming listen address with no circuit-relay transport can never be reserved on. Checked
859
+ // here, before start(), so the caller sees the omission by name rather than libp2p's generic
860
+ // unsupported-listen-address error for the bare `/p2p-circuit` the plan substituted.
861
+ assertCircuitRelayTransport(node, relayPlan.supervisedRelays);
862
+
777
863
  await node.start();
778
864
 
779
865
  // Everything from here to the `return` runs against an ALREADY STARTED node (open transports,
@@ -782,6 +868,24 @@ export async function createLibp2pNodeBase(
782
868
  // for the caller and enough to block the port for the next start attempt. So the whole post-start
783
869
  // body rolls back: see the `catch` at the bottom of this function.
784
870
  try {
871
+ // Keep every relay-naming listen address's reservation alive (see `relayPlan` above). Started
872
+ // first, with its stop wrapper installed before anything is awaited, so the rollback `catch`
873
+ // below stops it too. Awaiting the first drives keeps today's startup contract: a relay that
874
+ // cannot be reserved rejects node creation, naming the relay, exactly as libp2p's own
875
+ // `listen()` on the configured shape did. A host that would rather start offline and let the
876
+ // supervisor recover is a separate decision, not an option here.
877
+ if (relayPlan.supervisedRelays.length > 0) {
878
+ const relaySupervisors = superviseRelayReservations(node, relayPlan.supervisedRelays);
879
+ const previousStop = node.stop.bind(node);
880
+ node.stop = async () => {
881
+ try {
882
+ relaySupervisors.stop();
883
+ } finally {
884
+ await previousStop();
885
+ }
886
+ };
887
+ await relaySupervisors.awaitFirstDrives();
888
+ }
785
889
 
786
890
  // Initialize peer reputation service
787
891
  const reputation = new PeerReputationService();
@@ -867,7 +971,7 @@ export async function createLibp2pNodeBase(
867
971
  // voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
868
972
  // admitMembership). No FRET ⇒ confidence 0 ⇒ the gate fails closed for any downsize.
869
973
  const deriveExpectedCluster: DeriveExpectedClusterCallback = async (blockId) => {
870
- const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
974
+ const peers = await keyNetwork.findCluster(routingKeyForBlock(blockId));
871
975
  let confidence = 0;
872
976
  if (fretSvc) {
873
977
  try {
@@ -879,6 +983,13 @@ export async function createLibp2pNodeBase(
879
983
  return { peers: peers ?? {}, confidence };
880
984
  };
881
985
 
986
+ // Who holds each commit this node holds, for the rebalance monitor (so it does not push freshly
987
+ // committed blocks back to the members that stored them). Late-bound: the member and coordinator
988
+ // are built here, the monitor much further down inside the arachnode gate — and not at all when
989
+ // rebalance or FRET is off, in which case reports go nowhere.
990
+ let committedHoldersTarget: CommittedHoldersSink | undefined;
991
+ const onCommittedHolders: CommittedHoldersSink = (committed) => committedHoldersTarget?.(committed);
992
+
882
993
  clusterImpl = clusterMember({
883
994
  storageRepo,
884
995
  peerNetwork: keyNetwork,
@@ -893,6 +1004,7 @@ export async function createLibp2pNodeBase(
893
1004
  stateStore: options.transactionStateStore,
894
1005
  reconcileBlock,
895
1006
  onCommitCertificate,
1007
+ onCommittedHolders,
896
1008
  deriveExpectedCluster
897
1009
  // `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
898
1010
  // recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
@@ -993,7 +1105,11 @@ export async function createLibp2pNodeBase(
993
1105
  // funnel. `clusterLatestCallback` alone can only tell the reader WHICH revision the cohort
994
1106
  // holds; this is what moves the bytes. Only reached once a corroborated revision exists, so a
995
1107
  // genuinely absent block still costs no archive fetch.
996
- acquireBlockFromCohort: reconcileBlock
1108
+ acquireBlockFromCohort: reconcileBlock,
1109
+ // Records who is still missing each block of a commit acknowledged below `full`. Nothing
1110
+ // drains it yet: sending those copies is `under-replication-drain-and-full-replication-event`.
1111
+ underReplicationLedger,
1112
+ onCommittedHolders
997
1113
  });
998
1114
 
999
1115
  // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
@@ -1102,6 +1218,47 @@ export async function createLibp2pNodeBase(
1102
1218
  };
1103
1219
  }
1104
1220
 
1221
+ // --- Under-replication drain: send the copies the ledger says this node still owes ---
1222
+ // The coordinator repo above records, per block it acknowledged below `full`, who is still
1223
+ // missing a copy; this pushes those copies as the missing members become reachable and fires
1224
+ // the full-replication event on storageRepo when a block has every copy. Deliberately OUTSIDE
1225
+ // the arachnode `if (fret)` gate below: it needs the key network (cohort re-resolution for an
1226
+ // entry that could not name its members), the node's own store and dialer, and libp2p's
1227
+ // connection events — not the fretAdapter or the restoration coordinator. It overlaps the
1228
+ // rebalance growth arm and spread-on-churn on purpose: all three may push the same block to
1229
+ // the same peer, and the receiver is idempotent, so no cross-monitor coordination is added.
1230
+ // The sink it emits through is one method wide, so it can reach nothing else on the repo.
1231
+ let underReplicationDrain: UnderReplicationDrain | undefined;
1232
+ if ((options.underReplicationDrain?.enabled ?? true) !== false) {
1233
+ const { enabled: _enabled, pushDialTimeoutMs, pushResponseTimeoutMs, ...drainConfig } = options.underReplicationDrain ?? {};
1234
+ underReplicationDrain = new UnderReplicationDrain({
1235
+ libp2p: node,
1236
+ ledger: underReplicationLedger,
1237
+ keyNetwork,
1238
+ partitionDetector,
1239
+ protocolPrefix,
1240
+ pushBlock: (blockId, peerIds) => pushBlockToPeers(storageRepo, keyNetwork, blockId, peerIds, {
1241
+ reason: 'replication',
1242
+ protocolPrefix,
1243
+ dialTimeoutMs: pushDialTimeoutMs ?? 3000,
1244
+ responseTimeoutMs: pushResponseTimeoutMs ?? 10_000
1245
+ }),
1246
+ emit: (event) => storageRepo.emitBlockDurabilityReached(event)
1247
+ }, drainConfig);
1248
+ await underReplicationDrain.start();
1249
+ const previousStop = node.stop.bind(node);
1250
+ node.stop = async () => {
1251
+ try {
1252
+ await underReplicationDrain!.stop();
1253
+ } finally {
1254
+ await previousStop();
1255
+ }
1256
+ };
1257
+ }
1258
+ // Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor / node.rebalanceMonitor).
1259
+ (node as any).underReplicationDrain = underReplicationDrain;
1260
+ (node as any).underReplicationLedger = underReplicationLedger;
1261
+
1105
1262
  // Initialize Arachnode ring membership and restoration
1106
1263
  const enableArachnode = options.arachnode?.enableRingZulu ?? true;
1107
1264
  if (enableArachnode) {
@@ -1203,9 +1360,11 @@ export async function createLibp2pNodeBase(
1203
1360
  partitionDetector,
1204
1361
  fretAdapter,
1205
1362
  ownedBlocks,
1363
+ keyNetwork,
1206
1364
  options.rebalance,
1207
1365
  );
1208
1366
  await rebalanceMonitor.start();
1367
+ committedHoldersTarget = (committed) => rebalanceMonitor.recordCommittedHolders(committed);
1209
1368
 
1210
1369
  // onRebalance fires synchronously from the monitor's debounced check; the coordinator's
1211
1370
  // reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
@@ -1648,8 +1807,8 @@ export async function createLibp2pNodeBase(
1648
1807
  return undefined; // tail-less (read-driven promotion) never originates (the gate also returns first)
1649
1808
  }
1650
1809
  return {
1651
- // MUST reuse the gate's `reactivityTailBytes` (utf8), NOT db-core's double-hashing
1652
- // blockIdToBytes — else origination derives a different coord than subscribers resolve.
1810
+ // MUST reuse the gate's `reactivityTailBytes` (raw utf8, the tail's routing key), never
1811
+ // a pre-hashed digest — else origination derives a different coord than subscribers resolve.
1653
1812
  tailId: reactivityTailBytes(event.tailId),
1654
1813
  deltaMaxBytes: reactivityPolicy.deltaMaxBytes,
1655
1814
  // rotationHint stays undefined on a live node: the successor tail id is not knowable at the
@@ -1720,8 +1879,8 @@ export async function createLibp2pNodeBase(
1720
1879
  });
1721
1880
 
1722
1881
  // Inbound serve handler: decode (bounded) → verify the dialing peer's signature → freshness/replay gate →
1723
- // resolve the live PushState off the forwarder host → serveBackfill/serveResume → reply (no reply on any
1724
- // failure; the stream aborts and the subscriber walks/chain-reads). One node-level replay guard is shared
1882
+ // resolve the live PushState off the forwarder host → serveBackfill/serveResume → reply (a decline on any
1883
+ // failure; the subscriber's transport tries the next cohort member). One node-level replay guard is shared
1725
1884
  // across all recover requests — a plain pruned-on-access map, so no new timer to tear down.
1726
1885
  registerRecoverHandler(node, reactivityProtocols.recover, {
1727
1886
  pushStateFor: forwarderHost.pushStateFor.bind(forwarderHost),
@@ -1,30 +1,31 @@
1
- import {
2
- createLibp2pNodeBase,
3
- type Libp2pTransports,
4
- type NodeOptions,
5
- type RawStorageProvider,
6
- } from './libp2p-node-base.js';
7
- import type { OptimysticNode } from './optimystic-node.js';
8
-
9
- export type { Libp2pTransports, NodeOptions, RawStorageProvider };
10
-
11
- /**
12
- * React Native-friendly libp2p node factory.
13
- *
14
- * This entrypoint intentionally does not import Node-only transports (like `@libp2p/tcp`).
15
- * Callers must provide `options.transports` (and typically `options.listenAddrs`).
16
- */
17
- export async function createLibp2pNode(options: NodeOptions): Promise<OptimysticNode> {
18
- const transports = options.transports;
19
- if (!transports || transports.length === 0) {
20
- throw new Error(
21
- 'createLibp2pNode (RN) requires options.transports. ' +
22
- 'Provide an RN-compatible transport (e.g. WebSockets) and any required listenAddrs.'
23
- );
24
- }
25
-
26
- return await createLibp2pNodeBase(options, {
27
- listenAddrs: [],
28
- transports,
29
- });
30
- }
1
+ import {
2
+ createLibp2pNodeBase,
3
+ type Libp2pTransports,
4
+ type NodeOptions,
5
+ type RawStorageProvider,
6
+ type KVStoreProvider,
7
+ } from './libp2p-node-base.js';
8
+ import type { OptimysticNode } from './optimystic-node.js';
9
+
10
+ export type { Libp2pTransports, NodeOptions, RawStorageProvider, KVStoreProvider };
11
+
12
+ /**
13
+ * React Native-friendly libp2p node factory.
14
+ *
15
+ * This entrypoint intentionally does not import Node-only transports (like `@libp2p/tcp`).
16
+ * Callers must provide `options.transports` (and typically `options.listenAddrs`).
17
+ */
18
+ export async function createLibp2pNode(options: NodeOptions): Promise<OptimysticNode> {
19
+ const transports = options.transports;
20
+ if (!transports || transports.length === 0) {
21
+ throw new Error(
22
+ 'createLibp2pNode (RN) requires options.transports. ' +
23
+ 'Provide an RN-compatible transport (e.g. WebSockets) and any required listenAddrs.'
24
+ );
25
+ }
26
+
27
+ return await createLibp2pNodeBase(options, {
28
+ listenAddrs: [],
29
+ transports,
30
+ });
31
+ }
@@ -1,36 +1,37 @@
1
- import { tcp } from '@libp2p/tcp';
2
- import { webSockets } from '@libp2p/websockets';
3
- import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
4
- import {
5
- createLibp2pNodeBase,
6
- type Libp2pTransports,
7
- type NodeOptions,
8
- type RawStorageProvider,
9
- } from './libp2p-node-base.js';
10
- import type { OptimysticNode } from './optimystic-node.js';
11
-
12
- export type { Libp2pTransports, NodeOptions, RawStorageProvider };
13
-
14
- export async function createLibp2pNode(options: NodeOptions): Promise<OptimysticNode> {
15
- const port = options.port ?? 0;
16
- const wsHost = options.wsHost ?? '0.0.0.0';
17
-
18
- const defaultTransports: Libp2pTransports = [];
19
- const defaultListenAddrs: string[] = [];
20
-
21
- if (!options.disableTcp) {
22
- defaultTransports.push(tcp());
23
- defaultListenAddrs.push(`/ip4/0.0.0.0/tcp/${port}`);
24
- }
25
- if (options.wsPort !== undefined) {
26
- defaultTransports.push(webSockets());
27
- defaultListenAddrs.push(`/ip4/${wsHost}/tcp/${options.wsPort}/ws`);
28
- }
29
- // Always include the relay transport so this node can dial through relays
30
- defaultTransports.push(circuitRelayTransport());
31
-
32
- return await createLibp2pNodeBase(options, {
33
- listenAddrs: defaultListenAddrs,
34
- transports: defaultTransports,
35
- });
36
- }
1
+ import { tcp } from '@libp2p/tcp';
2
+ import { webSockets } from '@libp2p/websockets';
3
+ import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
4
+ import {
5
+ createLibp2pNodeBase,
6
+ type Libp2pTransports,
7
+ type NodeOptions,
8
+ type RawStorageProvider,
9
+ type KVStoreProvider,
10
+ } from './libp2p-node-base.js';
11
+ import type { OptimysticNode } from './optimystic-node.js';
12
+
13
+ export type { Libp2pTransports, NodeOptions, RawStorageProvider, KVStoreProvider };
14
+
15
+ export async function createLibp2pNode(options: NodeOptions): Promise<OptimysticNode> {
16
+ const port = options.port ?? 0;
17
+ const wsHost = options.wsHost ?? '0.0.0.0';
18
+
19
+ const defaultTransports: Libp2pTransports = [];
20
+ const defaultListenAddrs: string[] = [];
21
+
22
+ if (!options.disableTcp) {
23
+ defaultTransports.push(tcp());
24
+ defaultListenAddrs.push(`/ip4/0.0.0.0/tcp/${port}`);
25
+ }
26
+ if (options.wsPort !== undefined) {
27
+ defaultTransports.push(webSockets());
28
+ defaultListenAddrs.push(`/ip4/${wsHost}/tcp/${options.wsPort}/ws`);
29
+ }
30
+ // Always include the relay transport so this node can dial through relays
31
+ defaultTransports.push(circuitRelayTransport());
32
+
33
+ return await createLibp2pNodeBase(options, {
34
+ listenAddrs: defaultListenAddrs,
35
+ transports: defaultTransports,
36
+ });
37
+ }
package/src/logger.ts CHANGED
@@ -113,7 +113,12 @@ function formatError(v: Error, indent = ''): string {
113
113
  }
114
114
 
115
115
  function isAggregateError(err?: any): err is AggregateError {
116
- return err instanceof AggregateError || (err?.name === 'AggregateError' && Array.isArray(err.errors))
116
+ // `typeof` guards the bare identifier: if Hermes (React Native's JS engine) doesn't
117
+ // define a global `AggregateError`, `err instanceof AggregateError` would throw a
118
+ // ReferenceError right here on the error-formatting path — exactly where a thrown
119
+ // exception would otherwise go unreported. `typeof` never throws for an undeclared
120
+ // global, so this falls straight through to the duck-typed check either way.
121
+ return (typeof AggregateError !== 'undefined' && err instanceof AggregateError) || (err?.name === 'AggregateError' && Array.isArray(err.errors))
117
122
  }
118
123
 
119
124
  function printError(err: Error, indent = ''): string {
@@ -28,10 +28,9 @@
28
28
  * milestone); a *serving* tier-`d ≥ 1` query is gated on the promotion follow-ons, exactly as the seeker
29
29
  * walk is mock-tier-tagged-unimplemented. So this resolves the tier-0 engine for `coord_0(topicId)` only.
30
30
  *
31
- * **No-engine = no reply (anti-DoS).** A query for a topic this node holds no serving engine for produces
32
- * **no reply frame** — the handler never instantiates a `CoordEngine` from an inbound query (that would be
33
- * a DoS amplifier). The seeker maps a no-reply to a benign empty advisory result (the seeker ticket owns
34
- * that mapping).
31
+ * **No-engine = no result (anti-DoS).** A query for a topic this node holds no serving engine for gets
32
+ * **no result** (a zero-length reply) — the handler never instantiates a `CoordEngine` from an inbound query
33
+ * (that would be a DoS amplifier). The seeker maps a no-result to a benign empty advisory result.
35
34
  *
36
35
  * **Per-coord scoping.** The reply is built from exactly the `coord_0(topicId)` engine's store, nothing
37
36
  * cross-coord — matching how the register handler recomputes a served coord per frame. The serve side does
@@ -79,7 +78,7 @@ import type { CoordRegistry } from "../cohort-topic/host.js";
79
78
  import { peerIdToBytes } from "../cohort-topic/peer-codec.js";
80
79
  import { signPeer, verifyPeerSig } from "../cohort-topic/peer-sig.js";
81
80
  import { FretSizeEstimator } from "../cohort-topic/size-estimator.js";
82
- import { handleRequestResponse, requestResponse, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
81
+ import { handleRequestResponse, requestResponse, requireReply, DEFAULT_STREAM_MAX_BYTES } from "../cohort-topic/stream-util.js";
83
82
  import { DEFAULT_COHORT_TOPIC_PROTOCOLS } from "../cohort-topic/protocols.js";
84
83
  import { handleMatchmakingQuery } from "./query-handler.js";
85
84
  import { PROTOCOL_MATCHMAKING_QUERY, DEFAULT_MATCHMAKING_PROTOCOLS } from "./protocols.js";
@@ -339,9 +338,9 @@ export function createLibp2pMatchmakingTransport(deps: Libp2pMatchmakingTranspor
339
338
  }
340
339
  try {
341
340
  const frame = await requestResponse(node, peerIdFromString(primary), queryProtocol, encodeQueryV1(q, maxBytes), maxBytes);
342
- // The serve handler returns no frame for a topic it does not serve; map it (and any decode/dial
343
- // failure) to a benign empty reply so SeekerWalkClient.collect keeps walking rather than throwing.
344
- return frame.length === 0 ? emptyReply() : decodeQueryReplyV1(frame, maxBytes);
341
+ // The serve handler has no result for a topic it does not serve (`undefined`); map it (and any
342
+ // decode/dial failure) to a benign empty reply so SeekerWalkClient.collect keeps walking rather than throwing.
343
+ return frame === undefined ? emptyReply() : decodeQueryReplyV1(frame, maxBytes);
345
344
  } catch (err) {
346
345
  log("matchmaking query: dial/decode failed for primary %s (empty reply): %o", primary, err);
347
346
  return emptyReply();
@@ -390,7 +389,8 @@ export function createLibp2pMatchmakingTransport(deps: Libp2pMatchmakingTranspor
390
389
  throw new Error("matchmaking register: FRET routed the cohort primary to self; provide deps.selfServe.register");
391
390
  }
392
391
  const frame = await requestResponse(node, peerIdFromString(primary), registerProtocol, encodeCohortMessage(reg, maxBytes), maxBytes);
393
- return validateRegisterReplyV1(decodeCohortMessage(frame, maxBytes));
392
+ // The `/register` responder always writes a frame, so a no-result reply is a non-conforming peer: reject.
393
+ return validateRegisterReplyV1(decodeCohortMessage(requireReply(frame, "matchmaking register"), maxBytes));
394
394
  };
395
395
 
396
396
  /** Map a `RegisterReplyV1` to the walk's {@link SeekerProbeReply} (pass `result`; copy traffic/targetTier). */