@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,7 +1,7 @@
1
1
  import { createLibp2p } from 'libp2p';
2
2
  import { noise } from '@chainsafe/libp2p-noise';
3
3
  import { yamux } from '@chainsafe/libp2p-yamux';
4
- import { identify } from '@libp2p/identify';
4
+ import { identify, identifyPush } from '@libp2p/identify';
5
5
  import { ping } from '@libp2p/ping';
6
6
  import { dcutr } from '@libp2p/dcutr';
7
7
  import { autoNAT } from '@libp2p/autonat';
@@ -13,20 +13,24 @@ import { generateKeyPair } from '@libp2p/crypto/keys';
13
13
  import { clusterService } from './cluster/service.js';
14
14
  import { blockTransferService } from './cluster/block-transfer-service.js';
15
15
  import { repoService } from './repo/service.js';
16
- import { StorageRepo } from './storage/storage-repo.js';
16
+ import { StorageRepo, withBlockCommitLatch } from './storage/storage-repo.js';
17
17
  import { BlockStorage } from './storage/block-storage.js';
18
18
  import { MemoryRawStorage } from './storage/memory-storage.js';
19
+ import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
19
20
  import { clusterMember } from './cluster/cluster-repo.js';
21
+ import { selectQuorumRev, selectQuorumBlock, canonicalBlockHash } from './cluster/quorum-restore.js';
20
22
  import { createCommitCertStore, makeClusterCommitCertExtractor } from './cluster/commit-cert.js';
21
23
  import { coordinatorRepo } from './repo/coordinator-repo.js';
22
24
  import { Libp2pKeyPeerNetwork } from './libp2p-key-network.js';
23
25
  import { ClusterClient } from './cluster/client.js';
24
26
  import { networkManagerService } from './network/network-manager-service.js';
25
- import { fretService } from 'p2p-fret';
27
+ import { BlockTransferCoordinator } from './cluster/block-transfer.js';
28
+ import { fretService, Libp2pFretService } from 'p2p-fret';
26
29
  import { syncService } from './sync/service.js';
27
30
  import { SyncClient } from './sync/client.js';
28
- import { RestorationCoordinator } from './storage/restoration-coordinator-v2.js';
31
+ import { RestorationCoordinator } from './storage/restoration-coordinator.js';
29
32
  import { RingSelector } from './storage/ring-selector.js';
33
+ import { RingShiftCoordinator } from './storage/ring-shift-coordinator.js';
30
34
  import { StorageMonitor } from './storage/storage-monitor.js';
31
35
  import { ArachnodeFretAdapter } from './storage/arachnode-fret-adapter.js';
32
36
  import { createCohortTopicHost } from './cohort-topic/host.js';
@@ -43,14 +47,24 @@ import { DEFAULT_REACTIVITY_PROTOCOLS, reactivityProtocolList } from './reactivi
43
47
  import { registerMatchmakingQueryHandler } from './matchmaking/query-transport.js';
44
48
  import { DEFAULT_MATCHMAKING_PROTOCOLS, matchmakingProtocolList } from './matchmaking/protocols.js';
45
49
  import { signPeer } from './cohort-topic/peer-sig.js';
46
- import { createNotificationVerifier, createCorrelationReplayGuard, createStickyCohortHintCache, reactivityNodePolicy, createTierAddressing, createRingHash, Tier, b64urlToBytes, bytesToB64url, } from '@optimystic/db-core';
50
+ import { createNotificationVerifier, createCorrelationReplayGuard, createStickyCohortHintCache, reactivityNodePolicy, createTierAddressing, createRingHash, DEFAULT_SUPER_MAJORITY_THRESHOLD, Tier, b64urlToBytes, bytesToB64url, } from '@optimystic/db-core';
47
51
  import { PartitionDetector } from './cluster/partition-detector.js';
52
+ import { assertSuperMajorityCoupling } from './cluster/supermajority-coupling.js';
48
53
  import { createLogger } from './logger.js';
49
54
  import { PeerReputationService } from './reputation/peer-reputation.js';
55
+ import { PenaltyReason } from './reputation/types.js';
50
56
  import { DisputeService } from './dispute/dispute-service.js';
51
57
  import { DisputeClient } from './dispute/client.js';
58
+ import { sampleArbitrators } from './dispute/arbitrator-selection.js';
52
59
  /** Logger for the reactivity node-wiring (origination/forwarder/recover/rotation composition). */
53
60
  const reactivityWiringLog = createLogger('reactivity-node-wiring');
61
+ /**
62
+ * Logger for the best-effort in-factory service wiring. These injections run during `createLibp2p`
63
+ * internals against the unreliable `components.libp2p` proxy; the real node is re-injected
64
+ * post-construction (see the load-bearing block after `createLibp2p`), so a failure here is logged,
65
+ * not fatal.
66
+ */
67
+ const wiringLog = createLogger('node-wiring');
54
68
  function resolveStorage(provider) {
55
69
  if (!provider) {
56
70
  return new MemoryRawStorage();
@@ -96,6 +110,14 @@ export async function createLibp2pNodeBase(options, defaults) {
96
110
  };
97
111
  // Create shared storage layers with restoration callback
98
112
  const storageRepo = new StorageRepo((blockId) => new BlockStorage(blockId, rawStorage, restoreCallback));
113
+ // Per-block commit-latch runner, ready to thread into the invalidation-apply sink (`onInvalidate`)
114
+ // passed to `clusterMember(...)` and into each cascade `CollectionEnv`, the instant either is wired
115
+ // here. Sharing the `StorageRepo.commit:<blockId>` latch makes a compensating saveReplica/saveDeletion
116
+ // RMW of `meta.latest` mutually exclusive with a concurrent commit on the same block. It is unused
117
+ // today only because no `onInvalidate`/cascade driver is wired in the live node (see review handoff);
118
+ // it is bound here so that wiring is a one-liner and cannot reach for a divergent latch key.
119
+ const blockCommitLatch = withBlockCommitLatch;
120
+ void blockCommitLatch;
99
121
  let clusterImpl;
100
122
  let coordinatedRepo;
101
123
  const clusterProxy = {
@@ -150,19 +172,53 @@ export async function createLibp2pNodeBase(options, defaults) {
150
172
  listen: listenAddrs
151
173
  },
152
174
  connectionManager: {
153
- autoDial: true,
154
- minConnections: 1,
175
+ // `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
176
+ // ignored under the former `libp2pOptions as any` (removed with this change). This libp2p
177
+ // version has no such keys — auto-dial is now default connection-manager behavior with no
178
+ // direct replacement — so they are dropped rather than re-cast. See review handoff.
155
179
  maxConnections: 16,
156
- inboundConnectionUpgradeTimeout: 10_000,
157
- dialQueue: { concurrency: 2, attempts: 2 }
180
+ // Renamed from the stale `inboundConnectionUpgradeTimeout`. 10_000 equals this version's
181
+ // default, so surfacing (and correcting) the key is behavior-preserving; the old key was a no-op.
182
+ inboundUpgradeTimeout: 10_000
158
183
  },
159
184
  ...(options.connectionGater ? { connectionGater: options.connectionGater } : {}),
160
185
  transports,
161
186
  connectionEncrypters: [noise()],
162
187
  streamMuxers: [yamux()],
163
- services: {
188
+ // Narrow cast confined to the `services` field: the built-in factories (identify/dcutr/…) are
189
+ // typed against a SECOND copy of `@libp2p/interface` pulled in transitively (via `@libp2p/crypto`),
190
+ // whose `Uint8Array<ArrayBuffer>` vs `<ArrayBufferLike>` PeerId/key shapes are structurally
191
+ // incompatible with the top-level copy — a dependency-dedup artifact, not a real mismatch. The cast
192
+ // stays on this field alone so the rest of `libp2pOptions` remains fully typed as `Libp2pInit`.
193
+ // NOTE: this cast exists ONLY because of the duplicate @libp2p/interface install; if that dedups
194
+ // (or on a libp2p bump) drop `as unknown as NonNullable<Libp2pInit['services']>` and type the map directly.
195
+ services: ({
196
+ // `@libp2p/identify` is the ONE service here whose protocol id it builds itself:
197
+ // `Identify`/`IdentifyPush` both emit `/${protocolPrefix}/id[/push]/1.0.0`, always
198
+ // prepending the leading slash (its own default is the BARE `'ipfs'`). So this
199
+ // prefix must stay slash-LESS — passing `/optimystic/...` yields the malformed
200
+ // double-slash `//optimystic/<net>/id/1.0.0`. Every other service below
201
+ // (cluster/repo/sync/blockTransfer) concatenates its own template literal and so
202
+ // takes the slash-PREFIXED `protocolPrefix` form; do not unify the two.
203
+ // Locked by `identify-protocol-id.spec.ts`.
164
204
  identify: identify({
165
- protocolPrefix: `/optimystic/${options.networkName}`
205
+ protocolPrefix: `optimystic/${options.networkName}`
206
+ }),
207
+ // identify/push propagates *later* address/protocol changes (relay reservation,
208
+ // AutoNAT-learned observed addr, a service registered post-start) to already-connected
209
+ // peers. Without it those peers keep the stale snapshot from the initial identify.
210
+ // Two consequences, both now covered by tests rather than asserted here:
211
+ // - Addresses: a relay-only peer's reservation completes AFTER its first connection to
212
+ // the relay, so the circuit address is exactly the one identify cannot have carried.
213
+ // The relay's peerStore entry stays empty and a later dial by peer id alone fails
214
+ // with NoValidAddressesError against a reachable peer — `relay-address-propagation.spec.ts`
215
+ // (its gated control reproduces that failure with push removed).
216
+ // - Protocols: `membershipOf` in `libp2p-key-network.ts` classifies a peer serves/
217
+ // foreign/unknown purely from the peerStore protocol list, so a cluster/repo handler
218
+ // registered post-start never flips an already-connected peer to `serves` —
219
+ // `identify-push-propagation.spec.ts`.
220
+ identifyPush: identifyPush({
221
+ protocolPrefix: `optimystic/${options.networkName}`
166
222
  }),
167
223
  ping: ping(),
168
224
  // DCUtR (hole-punch) upgrades relayed node↔node connections to direct
@@ -255,10 +311,13 @@ export async function createLibp2pNodeBase(options, defaults) {
255
311
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5
256
312
  });
257
313
  const svc = svcFactory(components);
314
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
258
315
  try {
259
- svc.setLibp2p?.(components.libp2p);
316
+ svc.setLibp2p(components.libp2p);
317
+ }
318
+ catch (err) {
319
+ wiringLog('networkManager in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err);
260
320
  }
261
- catch { }
262
321
  return svc;
263
322
  },
264
323
  fret: (components) => {
@@ -271,60 +330,79 @@ export async function createLibp2pNodeBase(options, defaults) {
271
330
  bootstraps: options.bootstrapNodes ?? []
272
331
  });
273
332
  const svc = svcFactory(components);
333
+ // Best-effort proxy-time injection; the real node is re-injected post-construction below.
274
334
  try {
275
335
  svc.setLibp2p(components.libp2p);
276
336
  }
277
- catch { }
337
+ catch (err) {
338
+ wiringLog('fret in-factory setLibp2p failed (proxy); real node injected post-construction: %o', err);
339
+ }
278
340
  return svc;
279
341
  }
280
- },
342
+ // [dispute-subsystem-dormant] The /optimystic/<network>/dispute/1.0.0 handler
343
+ // (disputeProtocolService / DisputeProtocolService) is intentionally NOT registered here.
344
+ // The subsystem is staged dormant pending arbitrator-set anchoring — without it, a peer
345
+ // minting throwaway keypairs can forge a synthetic super-majority and pass resolution.
346
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
347
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
348
+ }),
281
349
  // Add bootstrap nodes as needed
282
350
  peerDiscovery: [
283
351
  ...(options.bootstrapNodes?.length ? [bootstrap({ list: options.bootstrapNodes })] : [])
284
352
  ],
285
353
  };
286
354
  const node = await createLibp2p(libp2pOptions);
287
- // Inject libp2p reference into services that need it before start
288
- try {
289
- node.services?.fret?.setLibp2p?.(node);
290
- }
291
- catch { }
292
- try {
293
- node.services?.networkManager?.setLibp2p?.(node);
294
- }
295
- catch { }
355
+ // Inject the REAL libp2p node into the services that need it, before start(). These are
356
+ // load-bearing and the node has NOT started yet, so any throw fails fast and rejects node
357
+ // creation (nothing started leaks) — far better than the service silently falling back to the
358
+ // unreliable `components.libp2p` proxy and surfacing later as routing/consensus failures.
359
+ const wired = node.services;
360
+ wired.fret.setLibp2p(node);
361
+ wired.networkManager.setLibp2p(node);
296
362
  // RepoService.checkRedirect resolves the network manager / self id / connection
297
363
  // addrs through this injected node (the components.libp2p proxy is unreliable
298
364
  // from inside a service at request time). Done before start() so the protocol
299
365
  // handler is live with a resolvable node from its first request.
300
- try {
301
- node.services?.repo?.setLibp2p?.(node);
302
- }
303
- catch { }
366
+ wired.repo.setLibp2p(node);
304
367
  await node.start();
305
368
  // Initialize peer reputation service
306
369
  const reputation = new PeerReputationService();
307
370
  // Initialize cluster coordination components
308
371
  const networkMode = (options.bootstrapNodes?.length ?? 0) > 0 ? 'joining' : 'forming';
309
- const keyNetwork = new Libp2pKeyPeerNetwork(node, options.clusterSize, undefined, networkMode, options.persistence, reputation);
310
- await keyNetwork.initFromPersistedState();
372
+ // Network-namespaced protocol prefix, threaded into the key network so coordinator/
373
+ // cohort selection is scoped to peers that serve THIS network's cluster/repo protocol.
374
+ // A peer that only belongs to another network sharing the same physical nodes/
375
+ // bootstraps registers a different (network-namespaced) identify protocol, so it is
376
+ // never selected and can't drag this network's super-majority below quorum.
311
377
  const protocolPrefix = `/optimystic/${options.networkName}`;
378
+ const keyNetwork = new Libp2pKeyPeerNetwork(node, options.clusterSize, undefined, networkMode, options.persistence, reputation, protocolPrefix);
379
+ await keyNetwork.initFromPersistedState();
312
380
  const createClusterClient = (peerId) => ClusterClient.create(peerId, keyNetwork, protocolPrefix);
313
- // Inject reputation into NetworkManagerService
381
+ // Inject reputation into NetworkManagerService. Load-bearing and non-optional: the service is
382
+ // unconditionally present, so a throw is a real wiring bug. Unlike the pre-start injections above
383
+ // the node has already started here, so stop it before rethrowing rather than leaking a started
384
+ // node + open transports (mirrors the cohortTopic hard-fail blocks below).
314
385
  try {
315
- node.services?.networkManager?.setReputation?.(reputation);
386
+ wired.networkManager.setReputation(reputation);
387
+ }
388
+ catch (err) {
389
+ await node.stop();
390
+ throw err;
316
391
  }
317
- catch { }
318
392
  // Create partition detector and get FRET service
319
393
  const partitionDetector = new PartitionDetector();
320
394
  const fretSvc = node.services?.fret;
321
395
  const consensusConfig = {
322
- superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? 0.67,
396
+ superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
323
397
  simpleMajorityThreshold: 0.51,
324
398
  minAbsoluteClusterSize: 2,
325
399
  allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
326
400
  clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
327
- partitionDetectionWindow: 60000
401
+ partitionDetectionWindow: 60000,
402
+ // Configured full cluster size — the member's own reference for "full size" in the membership
403
+ // admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
404
+ // self-shrink). Matches the size threaded into the coordinator below.
405
+ clusterSize: options.clusterSize ?? 10
328
406
  };
329
407
  // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
330
408
  // per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
@@ -361,9 +439,16 @@ export async function createLibp2pNodeBase(options, defaults) {
361
439
  const targets = cohortPeerIds.filter(id => id !== node.peerId.toString());
362
440
  if (targets.length === 0)
363
441
  return;
364
- const archives = await Promise.all(targets.map(peerIdStr => fetchArchiveFromPeer(peerIdStr, blockId)));
365
- let best;
366
- for (const archive of archives) {
442
+ const fetched = await Promise.all(targets.map(async (peerIdStr) => ({ peerIdStr, archive: await fetchArchiveFromPeer(peerIdStr, blockId) })));
443
+ // Each cohort archive contributes one (rev, actionId) claim from its max
444
+ // revision (>= the rev we committed). Pick the target rev by quorum
445
+ // corroboration rather than raw Math.max — a lone peer inflating its rev
446
+ // cannot steer reconciliation. Keep the serving peer + block per candidate
447
+ // so we can then verify content agreement.
448
+ // NOTE: this quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
449
+ // membership — deferred to backlog `debt-read-repair-commit-cert-verification`.
450
+ const candidates = [];
451
+ for (const { peerIdStr, archive } of fetched) {
367
452
  if (!archive)
368
453
  continue;
369
454
  const revs = Object.keys(archive.revisions).map(Number);
@@ -373,15 +458,57 @@ export async function createLibp2pNodeBase(options, defaults) {
373
458
  if (maxRev < committed.rev)
374
459
  continue;
375
460
  const data = archive.revisions[maxRev];
376
- if (!data?.block)
461
+ if (!data?.action)
377
462
  continue;
378
- if (!best || maxRev > best.source.rev) {
379
- best = { block: data.block, source: { actionId: data.action.actionId, rev: maxRev } };
463
+ candidates.push({ peerIdStr, rev: maxRev, actionId: data.action.actionId, block: data.block });
464
+ }
465
+ const revClaims = candidates.map(c => ({ peerId: c.peerIdStr, rev: c.rev, actionId: c.actionId }));
466
+ const selected = selectQuorumRev(revClaims, consensusConfig.simpleMajorityThreshold);
467
+ if (!selected)
468
+ return; // no rev corroborated by a quorum → leave block, churn/rebalance retries later
469
+ // Content agreement: among archives corroborating the chosen (rev, actionId)
470
+ // and actually carrying the block, the content must be byte-identical across
471
+ // a quorum. A cohort member serving content that hashes differently is rejected.
472
+ // NOTE: selectQuorumBlock recomputes its quorum over only the block-CARRYING
473
+ // corroborators, not the full rev-responder set. If most peers corroborate the
474
+ // rev but few carry block bytes (e.g. mid-prune), the content quorum can shrink
475
+ // to 2. Harmless with honest peers; if a colluding pair ever becomes the only
476
+ // block-servers for an agreed rev, that is the Sybil regime already deferred to
477
+ // backlog `debt-read-repair-commit-cert-verification`.
478
+ const corroborating = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
479
+ const hashCandidates = await Promise.all(corroborating.map(async (c) => ({ peerId: c.peerIdStr, hash: await canonicalBlockHash(c.block), block: c.block })));
480
+ const agreed = selectQuorumBlock(hashCandidates, consensusConfig.simpleMajorityThreshold);
481
+ if (!agreed)
482
+ return; // no content quorum → skip persist
483
+ // Best-effort: penalize cohort members that served content contradicting the
484
+ // agreed hash for the same committed (rev, actionId). Never let this throw.
485
+ try {
486
+ for (const c of hashCandidates) {
487
+ if (c.hash !== agreed.hash) {
488
+ reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
489
+ }
380
490
  }
381
491
  }
382
- if (best) {
383
- await storageRepo.saveReplicatedBlock(blockId, best.block, best.source);
492
+ catch { /* reputation write must never block restoration */ }
493
+ await storageRepo.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
494
+ };
495
+ // Member-side membership derivation for the admission gate: independently re-derive this block's
496
+ // responsible cluster from the SAME source the coordinator uses (IKeyNetwork.findCluster), plus FRET's
497
+ // network-size confidence. A member gates a coordinator-declared peer set against this view before
498
+ // voting, so a self-shrunk minority-partition set cannot be voted into super-majority (see cluster-repo
499
+ // admitMembership). No FRET ⇒ confidence 0 ⇒ the gate fails closed for any downsize.
500
+ const deriveExpectedCluster = async (blockId) => {
501
+ const peers = await keyNetwork.findCluster(new TextEncoder().encode(blockId));
502
+ let confidence = 0;
503
+ if (fretSvc) {
504
+ try {
505
+ confidence = fretSvc.getNetworkSizeEstimate().confidence;
506
+ }
507
+ catch {
508
+ // Leave confidence 0 → fail closed for downsizing.
509
+ }
384
510
  }
511
+ return { peers: peers ?? {}, confidence };
385
512
  };
386
513
  clusterImpl = clusterMember({
387
514
  storageRepo,
@@ -396,7 +523,8 @@ export async function createLibp2pNodeBase(options, defaults) {
396
523
  consensusConfig,
397
524
  stateStore: options.transactionStateStore,
398
525
  reconcileBlock,
399
- onCommitCertificate
526
+ onCommitCertificate,
527
+ deriveExpectedCluster
400
528
  // `recomputeArbitratorSet` (invalidation layer-2) is intentionally NOT wired here yet: a live FRET
401
529
  // recompute needs a churn-tolerance window so it does not false-reject legitimate certificates from
402
530
  // late-joiners (a liveness regression). Until that is tuned against live topology — and the
@@ -405,7 +533,7 @@ export async function createLibp2pNodeBase(options, defaults) {
405
533
  // `verifyInvalidationCertificate` and `tickets/plan/cohort-topic-membership-cert-trust-anchoring.md`.
406
534
  });
407
535
  const coordinatorRepoFactory = coordinatorRepo(keyNetwork, createClusterClient, {
408
- clusterSize: options.clusterSize ?? 10,
536
+ // clusterSize is now part of consensusConfig (member + coordinator share one reference).
409
537
  ...consensusConfig
410
538
  }, fretSvc, reputation, options.transactionStateStore);
411
539
  // Create callback for querying cluster peers for their latest block revision
@@ -448,36 +576,68 @@ export async function createLibp2pNodeBase(options, defaults) {
448
576
  localPeerId: node.peerId,
449
577
  clusterLatestCallback
450
578
  });
579
+ // Fail-fast coupling: the cluster member (what accepts a super-majority as sufficient) and the
580
+ // coordinator (what declares a transaction committed on that super-majority) MUST run the same
581
+ // threshold, or the node would come up able to disagree with itself mid-consensus. Both are fed from
582
+ // the single `consensusConfig` above; this asserts on their RESOLVED values so any future drift throws
583
+ // HERE at construction. See `assertSuperMajorityCoupling`.
584
+ assertSuperMajorityCoupling(clusterImpl, coordinatedRepo);
451
585
  // Recover persisted transaction state before accepting new requests
452
586
  if (options.transactionStateStore) {
453
587
  await clusterImpl.recoverTransactions();
454
588
  await coordinatedRepo.recoverTransactions();
455
589
  }
590
+ // --- Shared owned-block set for the resilience monitors ---
591
+ // SpreadOnChurnMonitor (sender) and RebalanceMonitor (responsibility tracker) both act on "the
592
+ // blocks this node physically holds". They share ONE Set so the two can never drift: a single
593
+ // owned-block feed populates it, and the rebalance responsibility-loss signal evicts from it
594
+ // (in the rebalance block below). Both monitors take this exact instance via deps.trackedBlocks.
595
+ const networkManager = node.services?.networkManager;
596
+ const ownedBlocks = new Set();
597
+ // Single owned-block feed: every block this node commits OR receives as a replica fires
598
+ // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
599
+ // node.blockChangeNotifier): the cohort-topic activation block below may replace
600
+ // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its own
601
+ // surface regardless of that opt-in. NOTE: this feed does NOT re-emit blocks already durable
602
+ // from a previous run; those are seeded once at startup by the storage-enumeration scan wired
603
+ // below (seedOwnedBlocksFromStorage), so a restarted node protects on-disk data without waiting
604
+ // for each block to be touched again. Registered lazily the first time a
605
+ // monitor that reads ownedBlocks is wired, so when BOTH monitors are disabled no subscription
606
+ // leaks; torn down exactly once in the stop wrapper below.
607
+ let offOwnedBlockFeed;
608
+ const ensureOwnedBlockFeed = () => {
609
+ if (offOwnedBlockFeed)
610
+ return;
611
+ offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
612
+ for (const blockId of e.blockIds)
613
+ ownedBlocks.add(blockId);
614
+ });
615
+ };
616
+ // Single owned-block-feed teardown. Registered up front (before either monitor's own stop
617
+ // wrapper) so it runs regardless of WHICH monitor subscribed the feed - including the
618
+ // spread-disabled / rebalance-only case. Idempotent: offOwnedBlockFeed is undefined-guarded.
619
+ {
620
+ const previousStop = node.stop.bind(node);
621
+ node.stop = async () => {
622
+ try {
623
+ offOwnedBlockFeed?.();
624
+ }
625
+ finally {
626
+ await previousStop();
627
+ }
628
+ };
629
+ }
456
630
  // --- Churn-resilient spread: drive SpreadOnChurnMonitor on a live node ---
457
631
  // Nothing previously activated the SENDING side of the churn-resilient spread protocol on a
458
- // real node. Here we init + start the monitor and feed it the blocks this node physically
459
- // holds, so a debounced connection:close re-pushes them to expansion-cohort peers (the
460
- // receiver durably persists each push via saveReplicatedBlock).
461
- const networkManager = node.services?.networkManager;
632
+ // real node. Here we init + start the monitor (sharing ownedBlocks) and ensure the single
633
+ // owned-block feed is live, so a debounced connection:close re-pushes the node's blocks to
634
+ // expansion-cohort peers (the receiver durably persists each push via saveReplicatedBlock).
462
635
  let spreadMonitor;
463
- let offOwnedBlockFeed;
464
636
  if (networkManager && (options.spreadOnChurn?.enabled ?? true) !== false) {
465
637
  try {
466
- spreadMonitor = networkManager.initSpreadOnChurnMonitor(partitionDetector, storageRepo, keyNetwork, options.clusterSize ?? 10, protocolPrefix, options.spreadOnChurn);
638
+ spreadMonitor = networkManager.initSpreadOnChurnMonitor(partitionDetector, storageRepo, keyNetwork, options.clusterSize ?? 10, protocolPrefix, ownedBlocks, options.spreadOnChurn);
467
639
  await spreadMonitor.start();
468
- // Feed owned blocks: every block this node commits OR receives as a replica fires
469
- // storageRepo.onAnyCollectionChange. Subscribe to storageRepo DIRECTLY (not
470
- // node.blockChangeNotifier): the cohort-topic activation block below may replace
471
- // blockChangeNotifier with a decorating bridge, but storageRepo keeps emitting on its
472
- // own surface regardless of that opt-in. NOTE: blocks already durable from a previous
473
- // run are NOT re-emitted on startup, so they are not tracked until next touched -
474
- // acceptable here (churn re-replication re-derives over time); an initial-scan is a
475
- // follow-on enhancement.
476
- const monitor = spreadMonitor;
477
- offOwnedBlockFeed = storageRepo.onAnyCollectionChange((e) => {
478
- for (const blockId of e.blockIds)
479
- monitor.trackBlock(blockId);
480
- });
640
+ ensureOwnedBlockFeed();
481
641
  }
482
642
  catch (err) {
483
643
  // Spread is a resilience optimization, not a correctness requirement - a wiring
@@ -488,16 +648,15 @@ export async function createLibp2pNodeBase(options, defaults) {
488
648
  }
489
649
  // Expose for tests/diagnostics (mirrors node.keyNetwork / node.reputation).
490
650
  node.spreadOnChurnMonitor = spreadMonitor;
491
- // Disposal: release the owned-block subscription + stop the monitor deterministically
492
- // before the transports close. Composes with the arachnode / clusterMember / cohort-topic
493
- // stop wrappers (each calls its captured previousStop last). Both steps are idempotent (the
494
- // unsubscribe is flag-guarded; SpreadOnChurnMonitor.stop early-returns when not running), so
495
- // a double node.stop() does not throw.
651
+ // Disposal: stop the spread monitor deterministically before the transports close. Composes
652
+ // with the arachnode / clusterMember / cohort-topic stop wrappers (each calls its captured
653
+ // previousStop last). Idempotent (SpreadOnChurnMonitor.stop early-returns when not running), so
654
+ // a double node.stop() does not throw. The owned-block feed teardown is the separate up-front
655
+ // wrapper above (shared across both monitors).
496
656
  {
497
657
  const previousStop = node.stop.bind(node);
498
658
  node.stop = async () => {
499
659
  try {
500
- offOwnedBlockFeed?.();
501
660
  if (spreadMonitor)
502
661
  await spreadMonitor.stop();
503
662
  }
@@ -512,14 +671,34 @@ export async function createLibp2pNodeBase(options, defaults) {
512
671
  const log = node.logger?.forComponent?.('db-p2p:arachnode');
513
672
  const fret = node.services?.fret;
514
673
  if (fret) {
515
- const fretAdapter = new ArachnodeFretAdapter(fret);
674
+ const fretAdapter = new ArachnodeFretAdapter(fret, node.peerId.toString());
675
+ // Blocks whose shed range has been RELEASED (Phase C of a ring shift, or a confirmed
676
+ // rebalance release). This is the GC-eligibility signal the future storage sweep
677
+ // (`st-storage-sweep-archival-and-capacity-estimate`) must consult: a block's local bytes may
678
+ // be reclaimed ONLY once it appears here, so an unconfirmed / still-served range is never
679
+ // swept. Populated strictly after replication is confirmed. See
680
+ // docs/arachnode-ring-handoff.md § Part 2 (Local bytes vs. tracking).
681
+ // NOTE: no sweep consumes this set yet; it is the coordinated eligibility handoff the sweep
682
+ // ticket will read. Until then it grows unbounded — bound it when the sweep lands.
683
+ const gcEligible = new Set();
684
+ node.gcEligibleBlocks = gcEligible;
685
+ // The ring-shift state machine (advertise→confirm→release). Wired inside the rebalance block
686
+ // below (it needs the BlockTransferCoordinator confirmer + the cohort-size floor); left
687
+ // undefined when the rebalance reaction is not wired, in which case ring shifts stay inert —
688
+ // a move-out is unsafe without the confirm/release path.
689
+ let ringShift;
516
690
  const storageMonitor = new StorageMonitor(rawStorage, options.arachnode?.storage ?? {});
517
691
  const ringSelector = new RingSelector(fretAdapter, storageMonitor, {
518
692
  minCapacity: 100 * 1024 * 1024,
519
693
  thresholds: {
520
694
  moveOut: 0.85,
521
695
  moveIn: 0.40
522
- }
696
+ },
697
+ // Damping so the ring decision cannot thrash near a boundary
698
+ // (docs/arachnode-ring-handoff.md § Part 1).
699
+ smoothingAlpha: 0.2,
700
+ deadband: 0.5,
701
+ minDwellMs: 10 * 60 * 1000
523
702
  });
524
703
  // Determine and announce ring membership
525
704
  const peerId = node.peerId.toString();
@@ -534,14 +713,142 @@ export async function createLibp2pNodeBase(options, defaults) {
534
713
  };
535
714
  // Replace the restore callback (this is a bit hacky, but works for now)
536
715
  storageRepo.createBlockStorage = (blockId) => new BlockStorage(blockId, rawStorage, newRestoreCallback);
537
- // Monitor capacity and adjust ring periodically
716
+ // --- Rebalance reaction: drive RebalanceMonitor + react via BlockTransferCoordinator ---
717
+ // Nothing previously activated the rebalance path on a real node: initRebalanceMonitor was
718
+ // never called, the monitor was never start()ed, and BlockTransferCoordinator (the
719
+ // pull-gained / push-lost reaction primitive) was never constructed in src. This block lives
720
+ // inside the arachnode `if (fret)` gate because both dependencies only exist here — the
721
+ // fretAdapter and the RestorationCoordinator. When arachnode is disabled or FRET is absent the
722
+ // rebalance path stays inert (acceptable: rebalance is a resilience optimization). A wiring
723
+ // failure here is non-fatal (log + continue), unlike the operator-opted-in cohortTopic block.
724
+ if (networkManager && (options.rebalance?.enabled ?? true) !== false) {
725
+ try {
726
+ // repo → the LOCAL storageRepo (not repoProxy/coordinatedRepo): a pulled/pushed replica
727
+ // must land in / be read from this node's own storage, same reasoning as the
728
+ // blockTransfer service handler registration. protocolPrefix (/optimystic/<networkName>)
729
+ // MUST match the prefix the node registers its block-transfer handler under, or every
730
+ // lost-block push dials the wrong protocol and fails to connect.
731
+ const coordinator = new BlockTransferCoordinator(storageRepo, keyNetwork, restorationCoordinatorV2, partitionDetector, protocolPrefix);
732
+ const rebalanceMonitor = networkManager.initRebalanceMonitor(partitionDetector, fretAdapter, ownedBlocks, options.rebalance);
733
+ await rebalanceMonitor.start();
734
+ // onRebalance fires synchronously from the monitor's debounced check; the coordinator's
735
+ // reaction (pull gained / push lost, each partition-guarded) is async, so hop it off the
736
+ // handler rather than blocking the monitor's emit loop. handleRebalanceEvent can REJECT
737
+ // (e.g. RestorationCoordinator.restore() throws while pulling a gained block) and a bare
738
+ // `void` would surface that as an unhandled rejection (process-fatal on Node >=15); the
739
+ // reaction is a resilience optimization, so swallow + log instead.
740
+ //
741
+ // ALONGSIDE dispatching to the coordinator, drive the shared owned-block set off this
742
+ // authoritative responsibility signal. A GAINED block is added immediately so it is
743
+ // tracked even before its next commit/replica touches the feed.
744
+ //
745
+ // A LOST block is NO LONGER released synchronously: doing so stopped spreading a block
746
+ // whose push to the new owners might fail, drop it below the replication floor, and let a
747
+ // later sweep reclaim it (docs/arachnode-ring-handoff.md § Why the current code violates
748
+ // it #2). Instead the release is GATED on confirmation — the coordinator returns the lost
749
+ // blocks it confirmed replicated to ≥ floor new owners, and ONLY those are untracked
750
+ // (authoritative eviction from the shared set — complements spread's lazy self-prune) and
751
+ // marked GC-eligible. A lost block whose push failed / was partition-skipped stays
752
+ // tracked and served, and is retried on the next rebalance.
753
+ //
754
+ // Best-effort iteration safety: this eviction can mutate ownedBlocks while
755
+ // SpreadOnChurnMonitor (or this monitor) is mid for...of over the same Set inside an
756
+ // async loop. Adding/deleting a Set entry during iteration does not throw in JS — entries
757
+ // are visited best-effort — which is acceptable for a resilience mechanism, so we
758
+ // document it here rather than add locking.
759
+ rebalanceMonitor.onRebalance((event) => {
760
+ for (const blockId of event.gained)
761
+ ownedBlocks.add(blockId);
762
+ coordinator.handleRebalanceEvent(event).then((result) => {
763
+ for (const blockId of result.released) {
764
+ rebalanceMonitor.untrackBlock(blockId); // also evicts from the shared ownedBlocks set
765
+ gcEligible.add(blockId); // confirmed replicated → safe to sweep
766
+ }
767
+ }).catch((err) => {
768
+ log?.('rebalance reaction failed: %o', err);
769
+ });
770
+ });
771
+ // Ring-shift handoff (advertise→confirm→release). It needs the confirmer (this
772
+ // coordinator) and the cohort-size floor (this monitor), so it is wired here. The
773
+ // `onRelease` callback runs Phase C's local effect: stop serving/spreading the shed
774
+ // range and mark it GC-eligible — the same authoritative eviction the confirmed-rebalance
775
+ // release performs.
776
+ ringShift = new RingShiftCoordinator({
777
+ fretAdapter,
778
+ ringSelector,
779
+ fret,
780
+ partitionDetector,
781
+ confirmer: coordinator,
782
+ ownedBlocks,
783
+ selfPeerId: peerId,
784
+ getFloor: () => rebalanceMonitor.getCohortSize(),
785
+ onRelease: (blockIds) => {
786
+ for (const blockId of blockIds) {
787
+ rebalanceMonitor.untrackBlock(blockId);
788
+ gcEligible.add(blockId);
789
+ }
790
+ }
791
+ });
792
+ // Reconcile any stale `moving` advertisement left by a crash mid-handoff (no-op unless
793
+ // arachnode metadata survived a restart still marked `moving`).
794
+ ringShift.reconcileOnStart();
795
+ // Feed owned blocks via the SINGLE shared feed (idempotent — already live if the spread
796
+ // block above wired it). Both monitors read the same ownedBlocks set this populates.
797
+ ensureOwnedBlockFeed();
798
+ // Expose for tests/diagnostics (mirrors node.spreadOnChurnMonitor).
799
+ node.rebalanceMonitor = rebalanceMonitor;
800
+ node.blockTransferCoordinator = coordinator;
801
+ node.ringShiftCoordinator = ringShift;
802
+ // Disposal: stop the monitor before transports close. Composes with the other stop
803
+ // wrappers (each calls its captured previousStop last). Idempotent — RebalanceMonitor.stop()
804
+ // early-returns when not running (NetworkManagerService.stop() also stops it). The shared
805
+ // owned-block feed teardown is the separate up-front wrapper (not duplicated here).
806
+ const previousStop = node.stop.bind(node);
807
+ node.stop = async () => {
808
+ try {
809
+ await rebalanceMonitor.stop();
810
+ }
811
+ finally {
812
+ await previousStop();
813
+ }
814
+ };
815
+ }
816
+ catch (err) {
817
+ // Rebalance is a resilience optimization, not a correctness requirement - a wiring
818
+ // failure (e.g. FRET briefly unavailable) must NOT hard-fail node startup.
819
+ log?.('rebalance wiring init failed: %o', err);
820
+ }
821
+ }
822
+ // Monitor capacity and adjust ring periodically. The damped `shouldTransition()` decides
823
+ // WHETHER/where to move (docs/arachnode-ring-handoff.md § Part 1); the RingShiftCoordinator
824
+ // carries the move out through the advertise→confirm→release handoff (§ Part 2) so a shift
825
+ // never drops a key below its replication floor. The old unilateral `setArachnodeInfo` flip —
826
+ // which changed advertised responsibility instantly with no data handoff — is gone.
827
+ //
828
+ // Ring shifts run ONLY when `ringShift` is wired (i.e. the rebalance reaction is enabled): a
829
+ // move-out is unsafe without the confirm/release path, so a node with the rebalance reaction
830
+ // disabled stays at its bootstrap ring rather than flipping unsafely.
538
831
  const monitorInterval = setInterval(async () => {
832
+ if (!ringShift)
833
+ return;
539
834
  const transition = await ringSelector.shouldTransition();
540
- if (transition.shouldMove) {
835
+ if (transition.shouldMove && transition.direction && transition.newRingDepth !== undefined) {
541
836
  log?.('Ring transition needed: moving %s to Ring %d', transition.direction, transition.newRingDepth);
542
- // Update Arachnode info with new ring
543
- const updatedInfo = await ringSelector.createArachnodeInfo(peerId);
544
- fretAdapter.setArachnodeInfo(updatedInfo);
837
+ try {
838
+ const outcome = await ringShift.executeShift({
839
+ direction: transition.direction,
840
+ newRingDepth: transition.newRingDepth
841
+ });
842
+ log?.('Ring shift outcome: %o', outcome);
843
+ }
844
+ catch (err) {
845
+ log?.('Ring shift failed: %o', err);
846
+ }
847
+ finally {
848
+ // Measure the minimum dwell from the SETTLED shift (completed or rolled back), not
849
+ // just the trigger stamped inside shouldTransition (docs/arachnode-ring-handoff.md §1.3).
850
+ ringSelector.recordShiftSettled();
851
+ }
545
852
  }
546
853
  }, 60_000);
547
854
  // Cleanup on node stop
@@ -555,6 +862,44 @@ export async function createLibp2pNodeBase(options, defaults) {
555
862
  log?.('FRET service not available, Arachnode disabled');
556
863
  }
557
864
  }
865
+ // --- Seed the shared owned-block set from already-durable storage ---
866
+ // Blocks durable from a previous run are otherwise untracked until next touched (see the
867
+ // onAnyCollectionChange comment above where ownedBlocks is declared). Placed here, AFTER both
868
+ // monitor-wiring blocks (spread ~line 862, rebalance ~line 974) have had their chance to call
869
+ // ensureOwnedBlockFeed():
870
+ // - Gate on offOwnedBlockFeed: only seed when a monitor actually consumes ownedBlocks; if both
871
+ // are disabled the set is unused and the scan (plus the background task) is wasted work.
872
+ // - Feed-before-scan ordering is load-bearing: because the feed is already live, a block
873
+ // committed/replicated DURING the scan is caught by the feed; Set.add is idempotent so the
874
+ // overlap is harmless. Scanning before subscribing would drop a block committed in the gap.
875
+ // - Fire-and-forget so a large store never blocks startup; the .catch keeps a scan rejection
876
+ // from becoming an unhandled rejection.
877
+ // - Cancellable: a stop wrapper flips seedStopping so the scan loop breaks against a
878
+ // stopping/closing backend rather than running the enumeration to completion.
879
+ // NOTE: a concurrent rebalance release can untrackBlock (delete from ownedBlocks) a confirmed-
880
+ // released block while this scan is still running, and the scan could then re-add that id. Benign
881
+ // transient: the block is still in the metadata store (no sweep reclaims metadata yet), so a
882
+ // re-added released block is simply re-evaluated and re-released on the next rebalance tick. Right
883
+ // after a restart, responsibility-loss detection lags this fast metadata scan, so the window is
884
+ // small. Accepted rather than synchronized.
885
+ if (offOwnedBlockFeed && typeof rawStorage.listBlockIds === 'function') {
886
+ let seedStopping = false;
887
+ const previousStop = node.stop.bind(node);
888
+ node.stop = async () => {
889
+ seedStopping = true;
890
+ await previousStop();
891
+ };
892
+ void seedOwnedBlocksFromStorage(rawStorage, ownedBlocks, () => seedStopping)
893
+ .catch((err) => (node.logger?.forComponent?.('db-p2p:owned-block-seed'))?.('seed failed: %o', err));
894
+ }
895
+ // [dispute-subsystem-dormant] The DisputeService object is constructed below so tests and
896
+ // getDisputeStatus() work, but it is unreachable from the live network path:
897
+ // - No inbound handler: disputeProtocolService is NOT in the services map above.
898
+ // - onInvalidation is deliberately unset: maybeInvalidate() is a no-op on live nodes.
899
+ // - revalidate is deliberately unset: handleChallenge always votes inconclusive on live nodes.
900
+ // Full activation requires arbitrator-set anchoring before a forged synthetic cohort can pass resolution.
901
+ // Gate: tickets/backlog/hardening/invalidation-live-wiring-requires-arbitrator-set-anchoring
902
+ // Wiring plan: tickets/backlog/feat-dispute-subsystem-live-activation
558
903
  // Initialize dispute service if enabled
559
904
  let disputeServiceInstance;
560
905
  if (options.dispute?.disputeEnabled) {
@@ -567,24 +912,25 @@ export async function createLibp2pNodeBase(options, defaults) {
567
912
  reputation,
568
913
  validator: options.validator,
569
914
  config: options.dispute,
570
- selectArbitrators: async (blockId, excludePeers, count) => {
915
+ selectArbitrators: async (blockId, excludePeers, count, round, epoch) => {
571
916
  const { hashKey: fretHashKey } = await import('p2p-fret');
572
- const blockIdBytes = new TextEncoder().encode(blockId);
573
917
  const fret = node.services?.fret;
574
918
  if (!fret)
575
919
  return [];
576
- // Get a larger cohort and exclude the original cluster peers
577
- const cohortSize = count + excludePeers.length + 1;
578
- const hashedCoord = await fretHashKey(blockIdBytes);
579
- const allPeerIdStrs = fret.assembleCohort(hashedCoord, cohortSize);
580
- // Filter out original cluster peers and self, convert to PeerId
920
+ // Dispersed sampling: draw `count` peers from coordinates spread across the whole keyspace
921
+ // (hash(blockId round epoch i)) rather than the block's XOR neighborhood, so an attacker
922
+ // who owns the block's locale does not thereby own the arbitrators. `assembleCohort` already
923
+ // filters to known members; excluding the original cluster + self keeps arbitrators independent.
581
924
  const excludeSet = new Set(excludePeers);
925
+ // NOTE: adding the local node's own id to `exclude` makes the draw node-relative. Cross-node
926
+ // determinism (the verifiable-recompute property) holds today only because the dissent
927
+ // coordinator running this is itself a member of the original cluster, so `self` is already in
928
+ // `excludePeers` — the add is a no-op and every honest node excludes the identical set. When a
929
+ // verify-path recompute lands, it MUST reconstruct `exclude` from the challenger's identity
930
+ // (`proof.challengerPeerId`) + original cluster, never the verifier's own id, or re-derivation diverges.
582
931
  excludeSet.add(node.peerId.toString());
583
- const arbitratorPeerIds = allPeerIdStrs
584
- .filter(pid => !excludeSet.has(pid))
585
- .slice(0, count)
586
- .map(pid => peerIdFromString(pid));
587
- return arbitratorPeerIds;
932
+ const picks = await sampleArbitrators({ blockId: new TextEncoder().encode(blockId), round, epoch, count, exclude: excludeSet }, (coord, wants) => fret.assembleCohort(coord, wants), fretHashKey);
933
+ return picks.map(pid => peerIdFromString(pid));
588
934
  },
589
935
  });
590
936
  }
@@ -607,6 +953,12 @@ export async function createLibp2pNodeBase(options, defaults) {
607
953
  node.keyNetwork = keyNetwork;
608
954
  node.reputation = reputation;
609
955
  node.disputeService = disputeServiceInstance;
956
+ // The node's libp2p Ed25519 identity key. Exposed on the same `(node as any).*` surface as
957
+ // coordinatedRepo/keyNetwork so a host can bind a client-transaction signer to it (the Quereus
958
+ // collection-factory's getSigner reuses this via signPeer). libp2p does not surface the private
959
+ // key on its public `Libp2p` interface, so this attachment is the sanctioned in-process handle.
960
+ // Ed25519 by construction (options.privateKey defaults to generateKeyPair('Ed25519')).
961
+ node.peerPrivateKey = nodePrivateKey;
610
962
  // --- Cohort-topic origination activation (post-node: consumes the fully-assembled node + FRET) ---
611
963
  // This is the only place that is after the node + FRET are assembled (node.start() done, fretSvc
612
964
  // available) yet before any caller can capture `blockChangeNotifier` — the Quereus collection-factory