@optimystic/db-p2p 0.24.1 → 0.25.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 (257) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +21 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +28 -17
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +78 -6
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +136 -57
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +150 -62
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/logger.d.ts +2 -2
  85. package/dist/src/logger.js +2 -2
  86. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  87. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  88. package/dist/src/network/open-protocol-stream.js +60 -0
  89. package/dist/src/network/open-protocol-stream.js.map +1 -0
  90. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  91. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  92. package/dist/src/network/register-protocol-handler.js +51 -0
  93. package/dist/src/network/register-protocol-handler.js.map +1 -0
  94. package/dist/src/peer-address-book.d.ts +128 -1
  95. package/dist/src/peer-address-book.d.ts.map +1 -1
  96. package/dist/src/peer-address-book.js +198 -15
  97. package/dist/src/peer-address-book.js.map +1 -1
  98. package/dist/src/pend-validation.d.ts +65 -0
  99. package/dist/src/pend-validation.d.ts.map +1 -0
  100. package/dist/src/pend-validation.js +69 -0
  101. package/dist/src/pend-validation.js.map +1 -0
  102. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  103. package/dist/src/reactivity/notify-transport.js +2 -1
  104. package/dist/src/reactivity/notify-transport.js.map +1 -1
  105. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  106. package/dist/src/reactivity/push-state-gossip.js +2 -1
  107. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  109. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  110. package/dist/src/repo/cluster-coordinator.js +44 -10
  111. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  112. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  113. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  114. package/dist/src/repo/coordinator-repo.js +650 -62
  115. package/dist/src/repo/coordinator-repo.js.map +1 -1
  116. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  117. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  118. package/dist/src/repo/served-repo-proxy.js +50 -0
  119. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  120. package/dist/src/repo/service.d.ts +22 -1
  121. package/dist/src/repo/service.d.ts.map +1 -1
  122. package/dist/src/repo/service.js +25 -13
  123. package/dist/src/repo/service.js.map +1 -1
  124. package/dist/src/reputation/types.d.ts +3 -1
  125. package/dist/src/reputation/types.d.ts.map +1 -1
  126. package/dist/src/reputation/types.js +3 -1
  127. package/dist/src/reputation/types.js.map +1 -1
  128. package/dist/src/rn.d.ts +24 -2
  129. package/dist/src/rn.d.ts.map +1 -1
  130. package/dist/src/rn.js +33 -4
  131. package/dist/src/rn.js.map +1 -1
  132. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  133. package/dist/src/routing/libp2p-known-peers.js +5 -0
  134. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  135. package/dist/src/storage/block-archive.d.ts +153 -0
  136. package/dist/src/storage/block-archive.d.ts.map +1 -0
  137. package/dist/src/storage/block-archive.js +194 -0
  138. package/dist/src/storage/block-archive.js.map +1 -0
  139. package/dist/src/storage/block-latch.d.ts +92 -0
  140. package/dist/src/storage/block-latch.d.ts.map +1 -0
  141. package/dist/src/storage/block-latch.js +132 -0
  142. package/dist/src/storage/block-latch.js.map +1 -0
  143. package/dist/src/storage/block-storage.d.ts +169 -12
  144. package/dist/src/storage/block-storage.d.ts.map +1 -1
  145. package/dist/src/storage/block-storage.js +415 -152
  146. package/dist/src/storage/block-storage.js.map +1 -1
  147. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  148. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  149. package/dist/src/storage/cached-raw-storage.js +16 -1
  150. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  151. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  152. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  153. package/dist/src/storage/cached-store-driver.js +39 -5
  154. package/dist/src/storage/cached-store-driver.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +70 -19
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js +21 -1
  158. package/dist/src/storage/i-block-storage.js.map +1 -1
  159. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  160. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  161. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  162. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  163. package/dist/src/storage/kv-raw-storage.js +29 -9
  164. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  165. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  166. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/memory-store-driver.js +22 -2
  168. package/dist/src/storage/memory-store-driver.js.map +1 -1
  169. package/dist/src/storage/raw-store-codec.js +2 -2
  170. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  171. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  172. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  173. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  174. package/dist/src/storage/shared-cache-pool.js +68 -4
  175. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  176. package/dist/src/storage/storage-repo.d.ts +204 -26
  177. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  178. package/dist/src/storage/storage-repo.js +422 -119
  179. package/dist/src/storage/storage-repo.js.map +1 -1
  180. package/dist/src/storage/store-identity.d.ts +21 -0
  181. package/dist/src/storage/store-identity.d.ts.map +1 -0
  182. package/dist/src/storage/store-identity.js +33 -0
  183. package/dist/src/storage/store-identity.js.map +1 -0
  184. package/dist/src/storage/struct.d.ts +15 -0
  185. package/dist/src/storage/struct.d.ts.map +1 -1
  186. package/dist/src/storage/with-read-cache.d.ts +92 -0
  187. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  188. package/dist/src/storage/with-read-cache.js +132 -0
  189. package/dist/src/storage/with-read-cache.js.map +1 -0
  190. package/dist/src/sync/service.d.ts +13 -3
  191. package/dist/src/sync/service.d.ts.map +1 -1
  192. package/dist/src/sync/service.js +7 -30
  193. package/dist/src/sync/service.js.map +1 -1
  194. package/dist/src/testing/mesh-harness.d.ts +75 -2
  195. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  196. package/dist/src/testing/mesh-harness.js +193 -81
  197. package/dist/src/testing/mesh-harness.js.map +1 -1
  198. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  199. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  200. package/dist/src/testing/raw-storage-conformance.js +97 -2
  201. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  202. package/package.json +5 -6
  203. package/src/cluster/block-transfer-service.ts +528 -312
  204. package/src/cluster/block-transfer.ts +547 -424
  205. package/src/cluster/certified-claims.ts +319 -0
  206. package/src/cluster/client-signature-verifier.ts +34 -0
  207. package/src/cluster/cluster-policy.ts +294 -203
  208. package/src/cluster/cluster-repo.ts +2501 -2034
  209. package/src/cluster/commit-proof.ts +344 -0
  210. package/src/cluster/quorum-restore.ts +348 -223
  211. package/src/cluster/rebalance-monitor.ts +539 -254
  212. package/src/cluster/reconcile-block.ts +404 -203
  213. package/src/cluster/service.ts +308 -293
  214. package/src/cluster/spread-on-churn.ts +352 -346
  215. package/src/cohort-topic/host.ts +8 -7
  216. package/src/cohort-topic/stream-util.ts +14 -43
  217. package/src/dispute/cascade.ts +31 -15
  218. package/src/dispute/dispute-service.ts +706 -696
  219. package/src/dispute/invalidation.ts +162 -52
  220. package/src/dispute/service.ts +126 -125
  221. package/src/index.ts +7 -0
  222. package/src/libp2p-key-network.ts +142 -58
  223. package/src/libp2p-node-base.ts +1793 -1675
  224. package/src/logger.ts +27 -27
  225. package/src/network/open-protocol-stream.ts +87 -0
  226. package/src/network/register-protocol-handler.ts +85 -0
  227. package/src/peer-address-book.ts +360 -149
  228. package/src/pend-validation.ts +89 -0
  229. package/src/reactivity/notify-transport.ts +145 -144
  230. package/src/reactivity/push-state-gossip.ts +292 -291
  231. package/src/repo/cluster-coordinator.ts +65 -11
  232. package/src/repo/coordinator-repo.ts +729 -71
  233. package/src/repo/served-repo-proxy.ts +55 -0
  234. package/src/repo/service.ts +332 -313
  235. package/src/reputation/types.ts +3 -1
  236. package/src/rn.ts +34 -4
  237. package/src/routing/libp2p-known-peers.ts +31 -26
  238. package/src/storage/block-archive.ts +242 -0
  239. package/src/storage/block-latch.ts +144 -0
  240. package/src/storage/block-storage.ts +787 -498
  241. package/src/storage/cached-raw-storage.ts +21 -1
  242. package/src/storage/cached-store-driver.ts +898 -859
  243. package/src/storage/i-block-storage.ts +172 -116
  244. package/src/storage/i-raw-storage.ts +40 -0
  245. package/src/storage/kv-raw-storage.ts +34 -9
  246. package/src/storage/memory-store-driver.ts +26 -2
  247. package/src/storage/raw-store-codec.ts +42 -42
  248. package/src/storage/raw-store-driver.ts +45 -3
  249. package/src/storage/shared-cache-pool.ts +516 -452
  250. package/src/storage/storage-repo.ts +1421 -1014
  251. package/src/storage/store-identity.ts +41 -0
  252. package/src/storage/struct.ts +52 -34
  253. package/src/storage/with-read-cache.ts +182 -0
  254. package/src/sync/service.ts +178 -198
  255. package/src/testing/cohort-topic-mesh-harness.ts +673 -673
  256. package/src/testing/mesh-harness.ts +524 -344
  257. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,312 +1,528 @@
1
- import type { Connection, Startable, Stream } from '@libp2p/interface';
2
- import type { IRepo, PeerId, IPeerNetwork, ActionId, ActionRev, IBlock, BlockId } from '@optimystic/db-core';
3
- import { pipe } from 'it-pipe';
4
- import * as lp from 'it-length-prefixed';
5
- import { fromString as u8FromString } from 'uint8arrays/from-string';
6
- import { toString as u8ToString } from 'uint8arrays/to-string';
7
- import { ProtocolClient } from '../protocol-client.js';
8
- import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js';
9
- import { createLogger } from '../logger.js';
10
- import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
11
-
12
- const log = createLogger('block-transfer-service');
13
-
14
- /** Protocol path */
15
- const BLOCK_TRANSFER_PREFIX = '/db-p2p/block-transfer/';
16
- const BLOCK_TRANSFER_VERSION = '1.0.0';
17
-
18
- export const buildBlockTransferProtocol = (protocolPrefix: string = ''): string =>
19
- `${protocolPrefix}${BLOCK_TRANSFER_PREFIX}${BLOCK_TRANSFER_VERSION}`;
20
-
21
- /** Request to transfer blocks */
22
- export interface BlockTransferRequest {
23
- type: 'pull' | 'push';
24
- /** Block IDs being transferred */
25
- blockIds: string[];
26
- /** Reason for transfer */
27
- reason: 'rebalance' | 'replication' | 'recovery';
28
- /** For push: base64-encoded block data per block ID */
29
- blockData?: Record<string, string>;
30
- /**
31
- * For push: the source's revision metadata per block ID. Carries the sender's
32
- * `state.latest` so the replica's `latest` matches the source instead of being
33
- * fabricated. Optional: an older sender omits it and the receiver falls back to a
34
- * deterministic rev-1 replica (see {@link IBlockStorage.saveReplica}).
35
- */
36
- blockMeta?: Record<string, { rev: number; actionId: ActionId }>;
37
- }
38
-
39
- /** Response with block data */
40
- export interface BlockTransferResponse {
41
- /** Blocks successfully transferred: blockId → base64-encoded data */
42
- blocks: Record<string, string>;
43
- /** Block IDs that couldn't be found/transferred */
44
- missing: string[];
45
- }
46
-
47
- // --- Service (server-side handler) ---
48
-
49
- /**
50
- * Repo capability the service needs: read access for `handlePull` plus a local
51
- * "save replica" path for `handlePush`. The replica path must land in the node's
52
- * *local* storage (not the cluster-coordinated repo), so it is a distinct method
53
- * from the `IRepo` commit funnel. `StorageRepo` implements this.
54
- */
55
- export interface IBlockReplicaStore extends IRepo {
56
- /**
57
- * Persist a replica of a block received out-of-band (churn re-replication).
58
- * Seeds metadata if absent, advances `latest` monotonically, and makes the block
59
- * durably servable via `get`. Idempotent for a fixed `(rev, actionId)`; a no-op
60
- * (still durable) when an equal-or-newer revision is already present.
61
- */
62
- saveReplicatedBlock(blockId: BlockId, block: IBlock, source?: ActionRev): Promise<void>;
63
- }
64
-
65
- export interface BlockTransferServiceInit extends InboundStreamAuthorizationInit {
66
- protocolPrefix?: string;
67
- }
68
-
69
- export interface BlockTransferServiceComponents {
70
- registrar: { handle: (...args: any[]) => Promise<void>; unhandle: (...args: any[]) => Promise<void> };
71
- repo: IBlockReplicaStore;
72
- /**
73
- * Optional libp2p component logger. Supplied by the node factory so authorization denials
74
- * land on the same `logger.forComponent(...).error` sink as the repo/cluster/sync services;
75
- * without it they fall back to this module's `debug` logger.
76
- */
77
- logger?: { forComponent: (name: string) => { error: (message: string, ...args: unknown[]) => void } };
78
- }
79
-
80
- /**
81
- * Libp2p service that handles incoming block transfer requests.
82
- *
83
- * Responds to pull requests by reading blocks from local storage.
84
- * Handles push requests by accepting block data and storing it locally.
85
- */
86
- export class BlockTransferService implements Startable {
87
- private running = false;
88
- private readonly protocol: string;
89
- private readonly repo: IBlockReplicaStore;
90
- private readonly registrar: BlockTransferServiceComponents['registrar'];
91
- /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
92
- private readonly authorization: InboundStreamAuthorization | undefined;
93
-
94
- constructor(
95
- components: BlockTransferServiceComponents,
96
- init: BlockTransferServiceInit = {}
97
- ) {
98
- this.protocol = buildBlockTransferProtocol(init.protocolPrefix ?? '');
99
- this.repo = components.repo;
100
- this.registrar = components.registrar;
101
- const componentLog = components.logger?.forComponent('db-p2p:block-transfer');
102
- this.authorization = createInboundStreamAuthorization(init, this.protocol,
103
- componentLog ? (msg, ...args) => componentLog.error(msg, ...args) : (msg, ...args) => log(msg, ...args));
104
- }
105
-
106
- async start(): Promise<void> {
107
- if (this.running) return;
108
- await this.registrar.handle(this.protocol, async (data: any, connection?: Connection) => {
109
- // libp2p invokes the stream handler with the Stream as the FIRST positional argument
110
- // (see cluster/repo/dispute services, which all use `(stream, connection)`). The block-
111
- // transfer handler previously read `data.stream`, which is `undefined` for the positional
112
- // shape — so `readRequest` ran `pipe(undefined, ...)` → "Empty pipeline", the receiver
113
- // never replied, and every push/pull dialled this service hung with no response. Unwrap
114
- // defensively (older shape passed `{ stream }`), mirroring sync/service.ts.
115
- const stream = data?.stream ?? data;
116
- await this.handleRequest(stream, connection);
117
- });
118
- this.running = true;
119
- log('started on %s', this.protocol);
120
- }
121
-
122
- async stop(): Promise<void> {
123
- if (!this.running) return;
124
- await this.registrar.unhandle(this.protocol);
125
- this.running = false;
126
- log('stopped');
127
- }
128
-
129
- private async handleRequest(stream: Stream, connection?: Connection): Promise<void> {
130
- const self = this;
131
- try {
132
- // Authorization runs before ANY decoding or execution. Guarded on the field so a
133
- // node without a predicate keeps the original path untouched.
134
- if (this.authorization && await this.authorization.deny(stream, connection?.remotePeer?.toString())) return;
135
- // Read the request, process it, and write the response on ONE continuous duplex
136
- // pipe (mirrors cluster/repo/dispute services). The earlier read-to-end-then-write
137
- // design deadlocked over a real stream: the client sends one length-prefixed request
138
- // and holds its write side open awaiting the reply, so a receiver that drained the
139
- // source until end-of-stream blocked forever — and the reply, written only after
140
- // teardown, hit a closed stream. Yielding the response as soon as the request is read
141
- // keeps both sides live.
142
- const responses = pipe(
143
- stream,
144
- (source) => lp.decode(source, { maxDataLength: MAX_BLOCK_MESSAGE_BYTES }),
145
- async function* (source) {
146
- for await (const msg of source) {
147
- const request = JSON.parse(u8ToString(msg.subarray(), 'utf8')) as BlockTransferRequest;
148
- log('request type=%s blocks=%d reason=%s', request.type, request.blockIds.length, request.reason);
149
- let response: BlockTransferResponse;
150
- try {
151
- response = request.type === 'pull'
152
- ? await self.handlePull(request)
153
- : await self.handlePush(request);
154
- } catch (error) {
155
- log('error: %s', (error as Error).message);
156
- response = { blocks: {}, missing: [] };
157
- }
158
- log('response blocks=%d missing=%d', Object.keys(response.blocks).length, response.missing.length);
159
- yield u8FromString(JSON.stringify(response), 'utf8');
160
- return; // one request one response per stream
161
- }
162
- },
163
- (source) => lp.encode(source)
164
- );
165
- for await (const chunk of responses) {
166
- stream.send(chunk);
167
- }
168
- await stream.close();
169
- } catch (err) {
170
- log('error: %s', (err as Error).message);
171
- try { stream.abort(err instanceof Error ? err : new Error(String(err))); } catch { /* ignore */ }
172
- }
173
- }
174
-
175
- private async handlePull(request: BlockTransferRequest): Promise<BlockTransferResponse> {
176
- const blocks: Record<string, string> = {};
177
- const missing: string[] = [];
178
-
179
- const result = await this.repo.get({ blockIds: request.blockIds });
180
-
181
- for (const blockId of request.blockIds) {
182
- const blockResult = result[blockId];
183
- if (blockResult?.block) {
184
- blocks[blockId] = Buffer.from(JSON.stringify(blockResult.block)).toString('base64');
185
- } else {
186
- missing.push(blockId);
187
- }
188
- }
189
-
190
- return { blocks, missing };
191
- }
192
-
193
- /**
194
- * Persist pushed blocks into local storage so the new owner holds a durable
195
- * replica after churn. A block is reported `accepted` only if it was both
196
- * received (parseable) AND successfully persisted; a parse or persist failure
197
- * surfaces it as `missing` so the sender does not falsely treat it as replicated.
198
- */
199
- private async handlePush(request: BlockTransferRequest): Promise<BlockTransferResponse> {
200
- const blocks: Record<string, string> = {};
201
- const missing: string[] = [];
202
-
203
- if (!request.blockData) {
204
- return { blocks: {}, missing: request.blockIds };
205
- }
206
-
207
- for (const blockId of request.blockIds) {
208
- const data = request.blockData[blockId];
209
- if (!data) {
210
- missing.push(blockId);
211
- continue;
212
- }
213
-
214
- // Decode + parse the wire payload into an IBlock.
215
- let block: IBlock;
216
- try {
217
- block = JSON.parse(Buffer.from(data, 'base64').toString('utf8')) as IBlock;
218
- } catch {
219
- missing.push(blockId);
220
- continue;
221
- }
222
-
223
- // `JSON.parse` accepts `null`/primitives as valid JSON. Persisting a falsy or
224
- // header-less "block" would seed metadata with no materialization, making every
225
- // later `get` throw. Reject such payloads as missing rather than poison storage.
226
- if (block === null || typeof block !== 'object' || (block as IBlock).header === undefined) {
227
- log('push:invalid block=%s (not a structurally valid block)', blockId);
228
- missing.push(blockId);
229
- continue;
230
- }
231
-
232
- // Persist locally. Only a received-AND-persisted block is reported accepted.
233
- try {
234
- const source = request.blockMeta?.[blockId];
235
- await this.repo.saveReplicatedBlock(blockId, block, source);
236
- blocks[blockId] = data;
237
- } catch (error) {
238
- log('persist:fail block=%s err=%s', blockId, (error as Error).message);
239
- missing.push(blockId);
240
- }
241
- }
242
-
243
- return { blocks, missing };
244
- }
245
- }
246
-
247
- /** Factory for creating BlockTransferService following the libp2p service pattern. */
248
- export const blockTransferService = (init: BlockTransferServiceInit = {}) =>
249
- (components: BlockTransferServiceComponents) => new BlockTransferService(components, init);
250
-
251
- // --- Client ---
252
-
253
- /**
254
- * Client for sending block transfer requests to remote peers.
255
- */
256
- export class BlockTransferClient extends ProtocolClient {
257
- private readonly protocol: string;
258
-
259
- constructor(
260
- peerId: PeerId,
261
- peerNetwork: IPeerNetwork,
262
- protocolPrefix: string = ''
263
- ) {
264
- super(peerId, peerNetwork);
265
- this.protocol = buildBlockTransferProtocol(protocolPrefix);
266
- }
267
-
268
- /**
269
- * Pull blocks from the remote peer.
270
- *
271
- * @param options Optional per-call deadlines/cancellation forwarded to the
272
- * underlying request. `dialTimeoutMs` bounds connecting; `responseTimeoutMs`
273
- * bounds waiting for the reply once connected; `signal` cancels the whole
274
- * request. Omitting all of them preserves the previous uncapped behavior.
275
- */
276
- async pullBlocks(
277
- blockIds: string[],
278
- reason: BlockTransferRequest['reason'] = 'rebalance',
279
- options?: { signal?: AbortSignal; dialTimeoutMs?: number; responseTimeoutMs?: number }
280
- ): Promise<BlockTransferResponse> {
281
- const request: BlockTransferRequest = { type: 'pull', blockIds, reason };
282
- return await this.processMessage<BlockTransferResponse>(request, this.protocol, { ...options, maxDataLength: MAX_BLOCK_MESSAGE_BYTES });
283
- }
284
-
285
- /**
286
- * Push blocks to the remote peer.
287
- *
288
- * @param blockMeta Optional per-block source revision metadata (the sender's
289
- * `state.latest`). When provided, the receiver replicates at the source's
290
- * `(rev, actionId)`; when omitted, it falls back to a deterministic rev-1 replica.
291
- * @param options Optional per-call deadlines/cancellation forwarded to the
292
- * underlying request. `dialTimeoutMs` bounds connecting; `responseTimeoutMs`
293
- * bounds waiting for the reply once connected (so a peer that connects but goes
294
- * silent throws {@link ResponseTimeoutError} instead of hanging); `signal`
295
- * cancels the whole request. Omitting all of them preserves the previous
296
- * uncapped behavior.
297
- */
298
- async pushBlocks(
299
- blockIds: string[],
300
- blockDataBuffers: Uint8Array[],
301
- reason: BlockTransferRequest['reason'] = 'rebalance',
302
- blockMeta?: Record<string, { rev: number; actionId: ActionId }>,
303
- options?: { signal?: AbortSignal; dialTimeoutMs?: number; responseTimeoutMs?: number }
304
- ): Promise<BlockTransferResponse> {
305
- const blockData: Record<string, string> = {};
306
- for (let i = 0; i < blockIds.length; i++) {
307
- blockData[blockIds[i]!] = Buffer.from(blockDataBuffers[i]!).toString('base64');
308
- }
309
- const request: BlockTransferRequest = { type: 'push', blockIds, reason, blockData, ...(blockMeta ? { blockMeta } : {}) };
310
- return await this.processMessage<BlockTransferResponse>(request, this.protocol, { ...options, maxDataLength: MAX_BLOCK_MESSAGE_BYTES });
311
- }
312
- }
1
+ import type { Connection, Startable, Stream } from '@libp2p/interface';
2
+ import type { IRepo, PeerId, IPeerNetwork, ActionId, ActionRev, GetBlockResult, IBlock, BlockId } from '@optimystic/db-core';
3
+ import { pipe } from 'it-pipe';
4
+ import * as lp from 'it-length-prefixed';
5
+ import { fromString as u8FromString } from 'uint8arrays/from-string';
6
+ import { toString as u8ToString } from 'uint8arrays/to-string';
7
+ import { ProtocolClient } from '../protocol-client.js';
8
+ import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js';
9
+ import { createLogger } from '../logger.js';
10
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
11
+ import { certifyContent, proofThresholds } from './certified-claims.js';
12
+ import type { BlockCommitProof, ProofThresholds } from './commit-proof.js';
13
+ import { servableProof, type ArchiveServingRepo } from '../storage/block-archive.js';
14
+ import { registerProtocolHandler } from '../network/register-protocol-handler.js';
15
+
16
+ const log = createLogger('block-transfer-service');
17
+
18
+ /** Protocol path */
19
+ const BLOCK_TRANSFER_PREFIX = '/db-p2p/block-transfer/';
20
+ const BLOCK_TRANSFER_VERSION = '1.0.0';
21
+
22
+ export const buildBlockTransferProtocol = (protocolPrefix: string = ''): string =>
23
+ `${protocolPrefix}${BLOCK_TRANSFER_PREFIX}${BLOCK_TRANSFER_VERSION}`;
24
+
25
+ /** Request to transfer blocks */
26
+ export interface BlockTransferRequest {
27
+ type: 'pull' | 'push';
28
+ /** Block IDs being transferred */
29
+ blockIds: string[];
30
+ /** Reason for transfer */
31
+ reason: 'rebalance' | 'replication' | 'recovery';
32
+ /** For push: base64-encoded block data per block ID */
33
+ blockData?: Record<string, string>;
34
+ /**
35
+ * For push: the source's revision metadata per block ID. Carries the sender's
36
+ * `state.latest` so the replica's `latest` matches the source instead of being
37
+ * fabricated. Optional: an older sender omits it and the receiver falls back to a
38
+ * deterministic rev-1 replica (see {@link IBlockStorage.saveReplica}).
39
+ */
40
+ blockMeta?: Record<string, { rev: number; actionId: ActionId }>;
41
+ /**
42
+ * For push: the cohort's commit proof for each block's revision. Verified against the pushed
43
+ * bytes AND against the declared {@link blockMeta} before anything is persisted — one
44
+ * `certifyContent` call covers the signatures and thresholds, the claim matching the declared
45
+ * `(rev, actionId)`, and the declared digest matching the pushed bytes.
46
+ *
47
+ * Optional on the wire so an un-upgraded sender still parses; whether a proof-less push is
48
+ * ACCEPTED is the receiver's `requirePushCertificate` decision (default: reject).
49
+ */
50
+ blockProofs?: Record<string, BlockCommitProof>;
51
+ }
52
+
53
+ /**
54
+ * The `blockMeta` a pusher sends alongside one block: the source's own `state.latest`, so the
55
+ * replica lands at the source's `(rev, actionId)` rather than a fabricated one. That match is what
56
+ * later lets the replica CORROBORATE the source in a read-repair quorum vote — a fabricated action
57
+ * id never matches the source's claim, so the vote sees one claimant, not two.
58
+ *
59
+ * `undefined` when the source repo reports no `latest`; the receiver then falls back to its
60
+ * deterministic rev-1 replica (see `IBlockStorage.saveReplica`).
61
+ *
62
+ * Every push path (rebalance confirm/push, spread-on-churn) builds it here so the wire shape stays
63
+ * defined in one place alongside {@link BlockTransferRequest.blockMeta}.
64
+ *
65
+ * The caller must read the block UNPINNED (no `BlockGets.context`). `state.latest` is the newest
66
+ * revision the source holds, while `block` is the revision `materialized` names; those agree only
67
+ * for an unpinned read. Pairing pinned (older) content with `latest` would label content with a
68
+ * revision it is not — so the pairing is guarded below and drops the meta rather than lying.
69
+ *
70
+ * Dropping, rather than labelling the older content with its own (now known) `materialized`
71
+ * revision, is deliberate: a push is "here is my latest replica" — the receiver lands it as its
72
+ * replica and the source's `latest` is what the cohort corroborates — and no push path reads
73
+ * pinned, so a mismatch here is a caller bug, not a historical serve. A repair fetch of an older
74
+ * revision is a different wire (`serveBlockArchive`), and that one DOES label from `materialized`.
75
+ */
76
+ export function sourceBlockMeta(
77
+ blockId: BlockId,
78
+ result: Pick<GetBlockResult, 'state' | 'materialized'> | undefined
79
+ ): Record<string, ActionRev> | undefined {
80
+ const latest = result?.state?.latest;
81
+ if (!latest) return undefined;
82
+ const materialized = result?.materialized;
83
+ if (materialized !== undefined && materialized.rev !== latest.rev) {
84
+ log('meta:skip block=%s materialized=%d latest=%d (pinned read refusing to mislabel content)',
85
+ blockId, materialized.rev, latest.rev);
86
+ return undefined;
87
+ }
88
+ return { [blockId]: { rev: latest.rev, actionId: latest.actionId } };
89
+ }
90
+
91
+ /**
92
+ * Everything a push carries ABOUT the blocks it pushes, as one value: the source's revision
93
+ * metadata and the cohort commit proof for that same revision. One parameter rather than two so a
94
+ * caller cannot attach a proof for one revision beside metadata for another — the two are built
95
+ * together by {@link sourceBlockCertification} from a single unpinned read, or not at all.
96
+ */
97
+ export type PushCertification = {
98
+ blockMeta?: Record<string, ActionRev>;
99
+ blockProofs?: Record<string, BlockCommitProof>;
100
+ };
101
+
102
+ /**
103
+ * Build a push's {@link PushCertification} for ONE block from a single unpinned read: the
104
+ * {@link sourceBlockMeta} for the source's `state.latest`, plus the locally-retained
105
+ * {@link BlockCommitProof} for exactly that revision.
106
+ *
107
+ * The proof comes from {@link servableProof} — the same accessor that decides what a peer attaches
108
+ * to a served repair archive so a push and a repair fetch can never disagree about which proof
109
+ * pairs with which revision. It fails closed on every unhappy path (repo with no proof accessor, a
110
+ * throwing lookup, a stored proof whose message names a different `(blockId, rev, actionId)`), so a
111
+ * mis-paired artifact is never pushed.
112
+ *
113
+ * **A proof is never attached without its meta.** Without the declared `(rev, actionId)` a receiver
114
+ * has no claim to verify the proof against, and it rejects the block (see
115
+ * `BlockTransferService.handlePush`).
116
+ *
117
+ * **Meta IS still attached when no proof exists.** That is the pre-proof push, unchanged: a
118
+ * receiver running the default `requirePushCertificate: true` rejects it either way, and one
119
+ * migrating with the flag `false` needs the meta to land the replica at the source's revision
120
+ * instead of a fabricated rev 1. Dropping the meta alongside the missing proof would regress the
121
+ * legacy path without making the strict path any stricter.
122
+ *
123
+ * Like `sourceBlockMeta`, the caller must have read the block UNPINNED, so `block`, the meta and
124
+ * the proof all describe the same revision.
125
+ */
126
+ export async function sourceBlockCertification(
127
+ repo: ArchiveServingRepo,
128
+ blockId: BlockId,
129
+ result: Pick<GetBlockResult, 'state' | 'materialized'> | undefined
130
+ ): Promise<PushCertification> {
131
+ const blockMeta = sourceBlockMeta(blockId, result);
132
+ if (!blockMeta) {
133
+ return {};
134
+ }
135
+ const latest = result!.state!.latest!;
136
+ const proof = await servableProof(repo, blockId, latest);
137
+ if (!proof) {
138
+ log('cert:no-local-proof block=%s rev=%d (pushing uncertified)', blockId, latest.rev);
139
+ return { blockMeta };
140
+ }
141
+ return { blockMeta, blockProofs: { [blockId]: proof } };
142
+ }
143
+
144
+ /** Response with block data */
145
+ export interface BlockTransferResponse {
146
+ /** Blocks successfully transferred: blockId base64-encoded data */
147
+ blocks: Record<string, string>;
148
+ /** Block IDs that couldn't be found/transferred */
149
+ missing: string[];
150
+ }
151
+
152
+ // --- Service (server-side handler) ---
153
+
154
+ /**
155
+ * Repo capability the service needs: read access for `handlePull` plus a local
156
+ * "save replica" path for `handlePush`. The replica path must land in the node's
157
+ * *local* storage (not the cluster-coordinated repo), so it is a distinct method
158
+ * from the `IRepo` commit funnel. `StorageRepo` implements this.
159
+ */
160
+ export interface IBlockReplicaStore extends IRepo {
161
+ /**
162
+ * Persist a replica of a block received out-of-band (churn re-replication).
163
+ * Seeds metadata if absent, advances `latest` monotonically, and makes the block
164
+ * durably servable via `get`. Idempotent for a fixed `(rev, actionId)`; a no-op
165
+ * (still durable) when an equal-or-newer revision is already present.
166
+ *
167
+ * `verifiedProof` MUST already have been verified against these exact bytes and this exact
168
+ * `source` (`certifyContent` — the digest check is what binds a proof to the content). It is
169
+ * retained so the receiver can re-prove onward what it just verified, instead of becoming a
170
+ * holder that can only corroborate.
171
+ */
172
+ saveReplicatedBlock(blockId: BlockId, block: IBlock, source?: ActionRev, verifiedProof?: BlockCommitProof): Promise<void>;
173
+ }
174
+
175
+ export interface BlockTransferServiceInit extends InboundStreamAuthorizationInit {
176
+ protocolPrefix?: string;
177
+ /**
178
+ * Reject a pushed block that carries no verifying commit proof. Default `true`.
179
+ * A deployment holding pre-proof data sets this `false` during migration.
180
+ *
181
+ * **Why the strict default.** `handlePush` persists what a peer hands it, and
182
+ * `saveReplicatedBlock` advances `latest` monotonically — so an uncertified push makes the
183
+ * pusher's bytes this node's authoritative revision, after which this node *corroborates* the
184
+ * pusher in a later read-repair vote. That is how a peer manufactures its own corroborators.
185
+ *
186
+ * The failure mode of `true` is that legacy blocks stop gaining new holders via push — visible
187
+ * in a `push:reject-uncertified` log line, and those blocks stay readable while two or more
188
+ * holders remain. The failure mode of `false` is silent acceptance of forged content. Pre-proof
189
+ * blocks can never be certified (the signatures no longer exist), but any block written again
190
+ * under the current code gets a proof, so only cold, never-updated blocks stay uncertified.
191
+ *
192
+ * With the flag `false` an uncertified push falls back to the pre-proof behaviour, logged as
193
+ * `push:accept-uncertified`. A push carrying a proof that FAILS verification is rejected
194
+ * regardless of the flag that is not a legacy block, it is a bad one.
195
+ */
196
+ requirePushCertificate?: boolean;
197
+ }
198
+
199
+ export interface BlockTransferServiceComponents {
200
+ registrar: { handle: (...args: any[]) => Promise<void>; unhandle: (...args: any[]) => Promise<void> };
201
+ repo: IBlockReplicaStore;
202
+ /**
203
+ * The cohort's configured super-majority fraction, used to verify a pushed block's commit proof.
204
+ *
205
+ * REQUIRED rather than defaulted: the node factory must hand this down from the single resolved
206
+ * `consensusConfig` every other consensus consumer reads (`libp2p-node-base.ts`, which already
207
+ * fail-fast asserts member/coordinator coupling on exactly this value). A local default here
208
+ * would be a third copy that silently disagrees with a deployment that tuned the threshold.
209
+ *
210
+ * The simple-majority half of {@link ProofThresholds} is deliberately NOT threaded: it must
211
+ * mirror what members actually enforce (`count > total / 2`), not the configured 0.51 — see
212
+ * `proofThresholds` in `certified-claims.ts`, the one helper every proof-verifying path builds
213
+ * its thresholds with.
214
+ */
215
+ superMajorityThreshold: number;
216
+ /**
217
+ * Optional libp2p component logger. Supplied by the node factory so authorization denials
218
+ * land on the same `logger.forComponent(...).error` sink as the repo/cluster/sync services;
219
+ * without it they fall back to this module's `debug` logger.
220
+ */
221
+ logger?: { forComponent: (name: string) => { error: (message: string, ...args: unknown[]) => void } };
222
+ }
223
+
224
+ /**
225
+ * Libp2p service that handles incoming block transfer requests.
226
+ *
227
+ * Responds to pull requests by reading blocks from local storage.
228
+ * Handles push requests by accepting block data and storing it locally.
229
+ */
230
+ export class BlockTransferService implements Startable {
231
+ private running = false;
232
+ private readonly protocol: string;
233
+ private readonly repo: IBlockReplicaStore;
234
+ private readonly registrar: BlockTransferServiceComponents['registrar'];
235
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
236
+ private readonly authorization: InboundStreamAuthorization | undefined;
237
+ /** Built once from the node's configured super-majority — see {@link BlockTransferServiceComponents.superMajorityThreshold}. */
238
+ private readonly proofThresholds: ProofThresholds;
239
+ /** See {@link BlockTransferServiceInit.requirePushCertificate}. */
240
+ private readonly requirePushCertificate: boolean;
241
+
242
+ constructor(
243
+ components: BlockTransferServiceComponents,
244
+ init: BlockTransferServiceInit = {}
245
+ ) {
246
+ this.protocol = buildBlockTransferProtocol(init.protocolPrefix ?? '');
247
+ this.repo = components.repo;
248
+ this.registrar = components.registrar;
249
+ this.proofThresholds = proofThresholds(components.superMajorityThreshold);
250
+ this.requirePushCertificate = init.requirePushCertificate ?? true;
251
+ const componentLog = components.logger?.forComponent('db-p2p:block-transfer');
252
+ this.authorization = createInboundStreamAuthorization(init, this.protocol,
253
+ componentLog ? (msg, ...args) => componentLog.error(msg, ...args) : (msg, ...args) => log(msg, ...args));
254
+ }
255
+
256
+ async start(): Promise<void> {
257
+ if (this.running) return;
258
+ await registerProtocolHandler(this.registrar, this.protocol, async (data: any, connection?: Connection) => {
259
+ // libp2p invokes the stream handler with the Stream as the FIRST positional argument
260
+ // (see cluster/repo/dispute services, which all use `(stream, connection)`). The block-
261
+ // transfer handler previously read `data.stream`, which is `undefined` for the positional
262
+ // shape so `readRequest` ran `pipe(undefined, ...)` → "Empty pipeline", the receiver
263
+ // never replied, and every push/pull dialled this service hung with no response. Unwrap
264
+ // defensively (older shape passed `{ stream }`), mirroring sync/service.ts.
265
+ const stream = data?.stream ?? data;
266
+ await this.handleRequest(stream, connection);
267
+ });
268
+ this.running = true;
269
+ log('started on %s', this.protocol);
270
+ }
271
+
272
+ async stop(): Promise<void> {
273
+ if (!this.running) return;
274
+ await this.registrar.unhandle(this.protocol);
275
+ this.running = false;
276
+ log('stopped');
277
+ }
278
+
279
+ private async handleRequest(stream: Stream, connection?: Connection): Promise<void> {
280
+ const self = this;
281
+ try {
282
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
283
+ // node without a predicate keeps the original path untouched.
284
+ if (this.authorization && await this.authorization.deny(stream, connection?.remotePeer?.toString())) return;
285
+ // Read the request, process it, and write the response on ONE continuous duplex
286
+ // pipe (mirrors cluster/repo/dispute services). The earlier read-to-end-then-write
287
+ // design deadlocked over a real stream: the client sends one length-prefixed request
288
+ // and holds its write side open awaiting the reply, so a receiver that drained the
289
+ // source until end-of-stream blocked forever and the reply, written only after
290
+ // teardown, hit a closed stream. Yielding the response as soon as the request is read
291
+ // keeps both sides live.
292
+ const responses = pipe(
293
+ stream,
294
+ (source) => lp.decode(source, { maxDataLength: MAX_BLOCK_MESSAGE_BYTES }),
295
+ async function* (source) {
296
+ for await (const msg of source) {
297
+ const request = JSON.parse(u8ToString(msg.subarray(), 'utf8')) as BlockTransferRequest;
298
+ log('request type=%s blocks=%d reason=%s', request.type, request.blockIds.length, request.reason);
299
+ let response: BlockTransferResponse;
300
+ try {
301
+ response = request.type === 'pull'
302
+ ? await self.handlePull(request)
303
+ : await self.handlePush(request);
304
+ } catch (error) {
305
+ log('error: %s', (error as Error).message);
306
+ response = { blocks: {}, missing: [] };
307
+ }
308
+ log('response blocks=%d missing=%d', Object.keys(response.blocks).length, response.missing.length);
309
+ yield u8FromString(JSON.stringify(response), 'utf8');
310
+ return; // one request one response per stream
311
+ }
312
+ },
313
+ (source) => lp.encode(source)
314
+ );
315
+ for await (const chunk of responses) {
316
+ stream.send(chunk);
317
+ }
318
+ await stream.close();
319
+ } catch (err) {
320
+ log('error: %s', (err as Error).message);
321
+ try { stream.abort(err instanceof Error ? err : new Error(String(err))); } catch { /* ignore */ }
322
+ }
323
+ }
324
+
325
+ private async handlePull(request: BlockTransferRequest): Promise<BlockTransferResponse> {
326
+ const blocks: Record<string, string> = {};
327
+ const missing: string[] = [];
328
+
329
+ const result = await this.repo.get({ blockIds: request.blockIds });
330
+
331
+ for (const blockId of request.blockIds) {
332
+ const blockResult = result[blockId];
333
+ if (blockResult?.block) {
334
+ blocks[blockId] = Buffer.from(JSON.stringify(blockResult.block)).toString('base64');
335
+ } else {
336
+ missing.push(blockId);
337
+ }
338
+ }
339
+
340
+ return { blocks, missing };
341
+ }
342
+
343
+ /**
344
+ * Persist pushed blocks into local storage so the new owner holds a durable
345
+ * replica after churn. A block is reported `accepted` only if it was both
346
+ * received (parseable) AND successfully persisted; a parse, certification or persist failure
347
+ * surfaces it as `missing` so the sender does not falsely treat it as replicated.
348
+ *
349
+ * **Certification.** A pushed block is content this node will serve — and, because
350
+ * `saveReplicatedBlock` advances `latest`, content it will later CORROBORATE in a read-repair
351
+ * vote. So by default the pusher must show the cohort's commit proof for the revision it
352
+ * declares, verified here against both the declared `(rev, actionId)` and the pushed bytes. See
353
+ * {@link BlockTransferServiceInit.requirePushCertificate} for the migration flag and its
354
+ * rationale.
355
+ *
356
+ * Decisions are strictly PER BLOCK: in a multi-block push a block that verifies is accepted
357
+ * beside one that does not. `handlePull` is unaffected — it serves this node's own storage.
358
+ */
359
+ private async handlePush(request: BlockTransferRequest): Promise<BlockTransferResponse> {
360
+ const blocks: Record<string, string> = {};
361
+ const missing: string[] = [];
362
+
363
+ if (!request.blockData) {
364
+ return { blocks: {}, missing: request.blockIds };
365
+ }
366
+
367
+ for (const blockId of request.blockIds) {
368
+ const data = request.blockData[blockId];
369
+ if (!data) {
370
+ missing.push(blockId);
371
+ continue;
372
+ }
373
+
374
+ // Decode + parse the wire payload into an IBlock.
375
+ let block: IBlock;
376
+ try {
377
+ block = JSON.parse(Buffer.from(data, 'base64').toString('utf8')) as IBlock;
378
+ } catch {
379
+ missing.push(blockId);
380
+ continue;
381
+ }
382
+
383
+ // `JSON.parse` accepts `null`/primitives as valid JSON. Persisting a falsy or
384
+ // header-less "block" would seed metadata with no materialization, making every
385
+ // later `get` throw. Reject such payloads as missing rather than poison storage.
386
+ if (block === null || typeof block !== 'object' || (block as IBlock).header === undefined) {
387
+ log('push:invalid block=%s (not a structurally valid block)', blockId);
388
+ missing.push(blockId);
389
+ continue;
390
+ }
391
+
392
+ // Certify BEFORE persisting: `saveReplicatedBlock` advances `latest`, so anything that
393
+ // reaches it becomes this node's authoritative revision.
394
+ const source = request.blockMeta?.[blockId];
395
+ const proof = request.blockProofs?.[blockId];
396
+ let verifiedProof: BlockCommitProof | undefined;
397
+ if (proof !== undefined) {
398
+ if (!source) {
399
+ // No declared `(rev, actionId)` means no claim to verify the proof against, and
400
+ // `saveReplica` would fabricate a rev-1 replica the proof does not cover.
401
+ log('push:reject-uncertified block=%s reason=proof-without-meta', blockId);
402
+ missing.push(blockId);
403
+ continue;
404
+ }
405
+ // One call covers the signatures and thresholds, the claim matching the declared
406
+ // `(rev, actionId)`, and the declared digest matching these exact bytes. Routed through
407
+ // `certifyContent` rather than the raw verifier so the wire-facing signer cap
408
+ // (MAX_PROOF_SIGNERS) the verifier requires of its callers is applied before any
409
+ // signature work — this input comes from an unauthenticated peer.
410
+ //
411
+ // NOTE: no `ProofAnchoring` is threaded here, unlike the other two proof-verifying
412
+ // paths (`ReconcileBlockDeps.anchoring`, `CoordinatorRepo`'s). Nothing wires anchoring
413
+ // in production today, so all three behave identically; when
414
+ // `feat-cluster-membership-threshold-cert-anchoring` lands, this call needs the same
415
+ // optional dep or the push path becomes the one place the unanchored residual is
416
+ // neither compared nor surfaced.
417
+ //
418
+ // NOTE: verification cost per REQUEST is unbounded in a way per-proof caps do not
419
+ // cover — one inbound push frame is capped at MAX_BLOCK_MESSAGE_BYTES (8 MiB, not the
420
+ // 1 MiB control cap `MAX_PROOF_SIGNERS`' note reasons about) and may carry one proof
421
+ // per block id. Each proof is bounded (≤ 256 signers), but their number is the
422
+ // sender's choice. Acceptable today because the pre-proof path already did per-block
423
+ // parse + disk write on the same frame; if inbound push CPU ever shows up in a
424
+ // profile, bound the per-request proof count (or total proof bytes) here.
425
+ const verdict = await certifyContent(
426
+ proof, { blockId, rev: source.rev, actionId: source.actionId }, block, this.proofThresholds);
427
+ if (!verdict.contentCertified) {
428
+ log('push:reject-uncertified block=%s rev=%d reason=%s', blockId, source.rev, verdict.failure);
429
+ missing.push(blockId);
430
+ continue;
431
+ }
432
+ verifiedProof = proof;
433
+ } else if (this.requirePushCertificate) {
434
+ // The documented migration case: an un-upgraded sender, or a block committed before
435
+ // proofs existed. Diagnosable by this line alone.
436
+ log('push:reject-uncertified block=%s rev=%s reason=no-proof', blockId, source?.rev);
437
+ missing.push(blockId);
438
+ continue;
439
+ } else {
440
+ log('push:accept-uncertified block=%s rev=%s (requirePushCertificate disabled)', blockId, source?.rev);
441
+ }
442
+
443
+ // Persist locally. Only a received-AND-persisted block is reported accepted.
444
+ try {
445
+ await this.repo.saveReplicatedBlock(blockId, block, source, verifiedProof);
446
+ blocks[blockId] = data;
447
+ } catch (error) {
448
+ log('persist:fail block=%s err=%s', blockId, (error as Error).message);
449
+ missing.push(blockId);
450
+ }
451
+ }
452
+
453
+ return { blocks, missing };
454
+ }
455
+ }
456
+
457
+ /** Factory for creating BlockTransferService following the libp2p service pattern. */
458
+ export const blockTransferService = (init: BlockTransferServiceInit = {}) =>
459
+ (components: BlockTransferServiceComponents) => new BlockTransferService(components, init);
460
+
461
+ // --- Client ---
462
+
463
+ /**
464
+ * Client for sending block transfer requests to remote peers.
465
+ */
466
+ export class BlockTransferClient extends ProtocolClient {
467
+ private readonly protocol: string;
468
+
469
+ constructor(
470
+ peerId: PeerId,
471
+ peerNetwork: IPeerNetwork,
472
+ protocolPrefix: string = ''
473
+ ) {
474
+ super(peerId, peerNetwork);
475
+ this.protocol = buildBlockTransferProtocol(protocolPrefix);
476
+ }
477
+
478
+ /**
479
+ * Pull blocks from the remote peer.
480
+ *
481
+ * @param options Optional per-call deadlines/cancellation forwarded to the
482
+ * underlying request. `dialTimeoutMs` bounds connecting; `responseTimeoutMs`
483
+ * bounds waiting for the reply once connected; `signal` cancels the whole
484
+ * request. Omitting all of them preserves the previous uncapped behavior.
485
+ */
486
+ async pullBlocks(
487
+ blockIds: string[],
488
+ reason: BlockTransferRequest['reason'] = 'rebalance',
489
+ options?: { signal?: AbortSignal; dialTimeoutMs?: number; responseTimeoutMs?: number }
490
+ ): Promise<BlockTransferResponse> {
491
+ const request: BlockTransferRequest = { type: 'pull', blockIds, reason };
492
+ return await this.processMessage<BlockTransferResponse>(request, this.protocol, { ...options, maxDataLength: MAX_BLOCK_MESSAGE_BYTES });
493
+ }
494
+
495
+ /**
496
+ * Push blocks to the remote peer.
497
+ *
498
+ * @param certification What this push claims about the blocks — the source's revision metadata
499
+ * and the cohort commit proof for that same revision, built as one unit by
500
+ * {@link sourceBlockCertification}. Without a proof the receiver rejects the block under its
501
+ * default `requirePushCertificate: true`; without metadata it replicates at a deterministic
502
+ * rev-1 (and rejects any proof, which then covers nothing).
503
+ * @param options Optional per-call deadlines/cancellation forwarded to the
504
+ * underlying request. `dialTimeoutMs` bounds connecting; `responseTimeoutMs`
505
+ * bounds waiting for the reply once connected (so a peer that connects but goes
506
+ * silent throws {@link ResponseTimeoutError} instead of hanging); `signal`
507
+ * cancels the whole request. Omitting all of them preserves the previous
508
+ * uncapped behavior.
509
+ */
510
+ async pushBlocks(
511
+ blockIds: string[],
512
+ blockDataBuffers: Uint8Array[],
513
+ reason: BlockTransferRequest['reason'] = 'rebalance',
514
+ certification?: PushCertification,
515
+ options?: { signal?: AbortSignal; dialTimeoutMs?: number; responseTimeoutMs?: number }
516
+ ): Promise<BlockTransferResponse> {
517
+ const blockData: Record<string, string> = {};
518
+ for (let i = 0; i < blockIds.length; i++) {
519
+ blockData[blockIds[i]!] = Buffer.from(blockDataBuffers[i]!).toString('base64');
520
+ }
521
+ const request: BlockTransferRequest = {
522
+ type: 'push', blockIds, reason, blockData,
523
+ ...(certification?.blockMeta ? { blockMeta: certification.blockMeta } : {}),
524
+ ...(certification?.blockProofs ? { blockProofs: certification.blockProofs } : {})
525
+ };
526
+ return await this.processMessage<BlockTransferResponse>(request, this.protocol, { ...options, maxDataLength: MAX_BLOCK_MESSAGE_BYTES });
527
+ }
528
+ }