@optimystic/db-p2p 0.2.3 → 0.3.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 (139) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +66 -0
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -0
  3. package/dist/src/cluster/block-transfer-service.js +163 -0
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -0
  5. package/dist/src/cluster/block-transfer.d.ts +79 -0
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -0
  7. package/dist/src/cluster/block-transfer.js +211 -0
  8. package/dist/src/cluster/block-transfer.js.map +1 -0
  9. package/dist/src/cluster/cluster-repo.d.ts +14 -3
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +80 -35
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/rebalance-monitor.d.ts +64 -0
  14. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -0
  15. package/dist/src/cluster/rebalance-monitor.js +159 -0
  16. package/dist/src/cluster/rebalance-monitor.js.map +1 -0
  17. package/dist/src/cluster/service.js +1 -1
  18. package/dist/src/cluster/service.js.map +1 -1
  19. package/dist/src/dispute/arbitrator-selection.d.ts +10 -0
  20. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -0
  21. package/dist/src/dispute/arbitrator-selection.js +22 -0
  22. package/dist/src/dispute/arbitrator-selection.js.map +1 -0
  23. package/dist/src/dispute/client.d.ts +17 -0
  24. package/dist/src/dispute/client.d.ts.map +1 -0
  25. package/dist/src/dispute/client.js +28 -0
  26. package/dist/src/dispute/client.js.map +1 -0
  27. package/dist/src/dispute/dispute-service.d.ts +81 -0
  28. package/dist/src/dispute/dispute-service.d.ts.map +1 -0
  29. package/dist/src/dispute/dispute-service.js +365 -0
  30. package/dist/src/dispute/dispute-service.js.map +1 -0
  31. package/dist/src/dispute/engine-health-monitor.d.ts +22 -0
  32. package/dist/src/dispute/engine-health-monitor.d.ts.map +1 -0
  33. package/dist/src/dispute/engine-health-monitor.js +75 -0
  34. package/dist/src/dispute/engine-health-monitor.js.map +1 -0
  35. package/dist/src/dispute/index.d.ts +7 -0
  36. package/dist/src/dispute/index.d.ts.map +1 -0
  37. package/dist/src/dispute/index.js +7 -0
  38. package/dist/src/dispute/index.js.map +1 -0
  39. package/dist/src/dispute/service.d.ts +41 -0
  40. package/dist/src/dispute/service.d.ts.map +1 -0
  41. package/dist/src/dispute/service.js +82 -0
  42. package/dist/src/dispute/service.js.map +1 -0
  43. package/dist/src/dispute/types.d.ts +106 -0
  44. package/dist/src/dispute/types.d.ts.map +1 -0
  45. package/dist/src/dispute/types.js +7 -0
  46. package/dist/src/dispute/types.js.map +1 -0
  47. package/dist/src/index.d.ts +3 -0
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/dist/src/index.js +3 -0
  50. package/dist/src/index.js.map +1 -1
  51. package/dist/src/libp2p-key-network.d.ts +23 -2
  52. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  53. package/dist/src/libp2p-key-network.js +100 -15
  54. package/dist/src/libp2p-key-network.js.map +1 -1
  55. package/dist/src/libp2p-node-base.d.ts +6 -0
  56. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  57. package/dist/src/libp2p-node-base.js +66 -12
  58. package/dist/src/libp2p-node-base.js.map +1 -1
  59. package/dist/src/logger.d.ts +1 -0
  60. package/dist/src/logger.d.ts.map +1 -1
  61. package/dist/src/logger.js +2 -0
  62. package/dist/src/logger.js.map +1 -1
  63. package/dist/src/network/network-manager-service.d.ts +15 -4
  64. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  65. package/dist/src/network/network-manager-service.js +33 -20
  66. package/dist/src/network/network-manager-service.js.map +1 -1
  67. package/dist/src/protocol-client.d.ts +1 -0
  68. package/dist/src/protocol-client.d.ts.map +1 -1
  69. package/dist/src/protocol-client.js +23 -2
  70. package/dist/src/protocol-client.js.map +1 -1
  71. package/dist/src/repo/client.d.ts +1 -0
  72. package/dist/src/repo/client.d.ts.map +1 -1
  73. package/dist/src/repo/client.js +18 -1
  74. package/dist/src/repo/client.js.map +1 -1
  75. package/dist/src/repo/cluster-coordinator.d.ts +3 -1
  76. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  77. package/dist/src/repo/cluster-coordinator.js +42 -2
  78. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  79. package/dist/src/repo/coordinator-repo.d.ts +18 -2
  80. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  81. package/dist/src/repo/coordinator-repo.js +62 -6
  82. package/dist/src/repo/coordinator-repo.js.map +1 -1
  83. package/dist/src/repo/service.d.ts +18 -2
  84. package/dist/src/repo/service.d.ts.map +1 -1
  85. package/dist/src/repo/service.js +88 -91
  86. package/dist/src/repo/service.js.map +1 -1
  87. package/dist/src/reputation/index.d.ts +3 -0
  88. package/dist/src/reputation/index.d.ts.map +1 -0
  89. package/dist/src/reputation/index.js +3 -0
  90. package/dist/src/reputation/index.js.map +1 -0
  91. package/dist/src/reputation/peer-reputation.d.ts +23 -0
  92. package/dist/src/reputation/peer-reputation.d.ts.map +1 -0
  93. package/dist/src/reputation/peer-reputation.js +121 -0
  94. package/dist/src/reputation/peer-reputation.js.map +1 -0
  95. package/dist/src/reputation/types.d.ts +89 -0
  96. package/dist/src/reputation/types.d.ts.map +1 -0
  97. package/dist/src/reputation/types.js +42 -0
  98. package/dist/src/reputation/types.js.map +1 -0
  99. package/dist/src/storage/arachnode-fret-adapter.d.ts +5 -0
  100. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  101. package/dist/src/storage/arachnode-fret-adapter.js +10 -0
  102. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  103. package/dist/src/storage/block-storage.d.ts.map +1 -1
  104. package/dist/src/storage/block-storage.js +5 -0
  105. package/dist/src/storage/block-storage.js.map +1 -1
  106. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  107. package/dist/src/storage/storage-repo.js +8 -0
  108. package/dist/src/storage/storage-repo.js.map +1 -1
  109. package/package.json +11 -10
  110. package/src/cluster/block-transfer-service.ts +228 -0
  111. package/src/cluster/block-transfer.ts +284 -0
  112. package/src/cluster/cluster-repo.ts +93 -38
  113. package/src/cluster/rebalance-monitor.ts +225 -0
  114. package/src/dispute/arbitrator-selection.ts +28 -0
  115. package/src/dispute/client.ts +41 -0
  116. package/src/dispute/dispute-service.ts +453 -0
  117. package/src/dispute/engine-health-monitor.ts +86 -0
  118. package/src/dispute/index.ts +17 -0
  119. package/src/dispute/service.ts +119 -0
  120. package/src/dispute/types.ts +114 -0
  121. package/src/index.ts +3 -0
  122. package/src/libp2p-key-network.ts +120 -22
  123. package/src/libp2p-node-base.ts +77 -13
  124. package/src/logger.ts +2 -1
  125. package/src/network/network-manager-service.ts +47 -16
  126. package/src/protocol-client.ts +29 -7
  127. package/src/repo/client.ts +20 -6
  128. package/src/repo/cluster-coordinator.ts +43 -2
  129. package/src/repo/coordinator-repo.ts +70 -7
  130. package/src/repo/redirect.ts +0 -2
  131. package/src/repo/service.ts +95 -87
  132. package/src/reputation/index.ts +12 -0
  133. package/src/reputation/peer-reputation.ts +147 -0
  134. package/src/reputation/types.ts +117 -0
  135. package/src/storage/arachnode-fret-adapter.ts +11 -0
  136. package/src/storage/block-storage.ts +6 -0
  137. package/src/storage/storage-repo.ts +9 -0
  138. package/dist/index.min.js +0 -53
  139. package/dist/index.min.js.map +0 -7
@@ -3,6 +3,11 @@ import { peerIdFromString } from '@libp2p/peer-id'
3
3
  import type { FretService } from 'p2p-fret'
4
4
  import { hashKey } from 'p2p-fret'
5
5
  import { toString as u8ToString } from 'uint8arrays/to-string'
6
+ import type { IPeerReputation } from '../reputation/types.js'
7
+ import { PenaltyReason } from '../reputation/types.js'
8
+ import { RebalanceMonitor, type RebalanceMonitorConfig } from '../cluster/rebalance-monitor.js'
9
+ import type { PartitionDetector } from '../cluster/partition-detector.js'
10
+ import type { ArachnodeFretAdapter } from '../storage/arachnode-fret-adapter.js'
6
11
 
7
12
  export type NetworkManagerServiceInit = {
8
13
  clusterSize?: number
@@ -33,9 +38,9 @@ export class NetworkManagerService implements Startable {
33
38
  private readonly coordinatorCache = new Map<string, { id: PeerId, expires: number }>()
34
39
  private readonly clusterCache = new Map<string, { ids: PeerId[], expires: number }>()
35
40
  private lastEstimate: { estimate: number, samples: number, updated: number } | null = null
36
- // lightweight blacklist (local reputation)
37
- private readonly blacklist = new Map<string, { score: number, expires: number }>()
41
+ private reputation?: IPeerReputation
38
42
  private libp2pRef: Libp2p | undefined
43
+ private rebalanceMonitor?: RebalanceMonitor
39
44
 
40
45
  constructor(private readonly components: Components, init: NetworkManagerServiceInit = {}) {
41
46
  this.log = components.logger.forComponent('db-p2p:network-manager')
@@ -55,6 +60,34 @@ export class NetworkManagerService implements Startable {
55
60
  this.libp2pRef = libp2p;
56
61
  }
57
62
 
63
+ setReputation(reputation: IPeerReputation): void {
64
+ this.reputation = reputation;
65
+ }
66
+
67
+ /**
68
+ * Initialize the rebalance monitor. Call after libp2p, FRET, and adapter are available.
69
+ */
70
+ initRebalanceMonitor(
71
+ partitionDetector: PartitionDetector,
72
+ fretAdapter: ArachnodeFretAdapter,
73
+ config?: RebalanceMonitorConfig
74
+ ): RebalanceMonitor {
75
+ const libp2p = this.getLibp2p()
76
+ const fret = this.getFret()
77
+ if (!libp2p || !fret) {
78
+ throw new Error('Cannot init RebalanceMonitor: libp2p or FRET not available')
79
+ }
80
+ this.rebalanceMonitor = new RebalanceMonitor(
81
+ { libp2p, fret, partitionDetector, fretAdapter },
82
+ config
83
+ )
84
+ return this.rebalanceMonitor
85
+ }
86
+
87
+ getRebalanceMonitor(): RebalanceMonitor | undefined {
88
+ return this.rebalanceMonitor
89
+ }
90
+
58
91
  private getLibp2p(): Libp2p | undefined {
59
92
  return this.libp2pRef ?? this.components.libp2p;
60
93
  }
@@ -77,6 +110,9 @@ export class NetworkManagerService implements Startable {
77
110
  }
78
111
 
79
112
  async stop(): Promise<void> {
113
+ if (this.rebalanceMonitor) {
114
+ await this.rebalanceMonitor.stop()
115
+ }
80
116
  this.running = false
81
117
  }
82
118
 
@@ -162,23 +198,14 @@ export class NetworkManagerService implements Startable {
162
198
  }
163
199
 
164
200
  /**
165
- * Record a misbehaving peer. Higher score means worse reputation.
166
- * Entries expire to allow eventual forgiveness.
201
+ * Record a misbehaving peer. Delegates to IPeerReputation if available.
167
202
  */
168
- reportBadPeer(peerId: PeerId, penalty: number = 1, ttlMs: number = 10 * 60_000): void {
169
- const id = peerId.toString()
170
- const prev = this.blacklist.get(id)
171
- const score = (prev?.score ?? 0) + Math.max(1, penalty)
172
- this.blacklist.set(id, { score, expires: Date.now() + ttlMs })
203
+ reportBadPeer(peerId: PeerId, reason: PenaltyReason = PenaltyReason.ConnectionFailure): void {
204
+ this.reputation?.reportPeer(peerId.toString(), reason)
173
205
  }
174
206
 
175
207
  private isBlacklisted(peerId: PeerId): boolean {
176
- const id = peerId.toString()
177
- const rec = this.blacklist.get(id)
178
- if (!rec) return false
179
- if (rec.expires <= Date.now()) { this.blacklist.delete(id); return false }
180
- // simple threshold; can be tuned or exposed later
181
- return rec.score >= 3
208
+ return this.reputation?.isBanned(peerId.toString()) ?? false
182
209
  }
183
210
 
184
211
  recordCoordinator(key: Uint8Array, peerId: PeerId): void {
@@ -298,7 +325,11 @@ export class NetworkManagerService implements Startable {
298
325
  if (!libp2p) {
299
326
  throw new Error('Libp2p not initialized');
300
327
  }
301
- const candidate = cluster.find(p => !this.isBlacklisted(p)) ?? libp2p.peerId;
328
+ // Prefer non-banned, non-deprioritized peers; fall back to deprioritized before self
329
+ const candidate = cluster
330
+ .filter(p => !this.isBlacklisted(p))
331
+ .sort((a, b) => (this.reputation?.getScore(a.toString()) ?? 0) - (this.reputation?.getScore(b.toString()) ?? 0))
332
+ [0] ?? libp2p.peerId;
302
333
  this.recordCoordinator(key, candidate);
303
334
  return candidate;
304
335
  }
@@ -3,6 +3,9 @@ import { encode as lpEncode, decode as lpDecode } from 'it-length-prefixed';
3
3
  import type { Stream as Libp2pStream } from '@libp2p/interface';
4
4
  import type { PeerId, IPeerNetwork } from '@optimystic/db-core';
5
5
  import { first } from './it-utility.js';
6
+ import { createLogger } from './logger.js';
7
+
8
+ const log = createLogger('protocol-client');
6
9
 
7
10
  /** Base class for clients that communicate via a libp2p protocol */
8
11
  export class ProtocolClient {
@@ -14,13 +17,25 @@ export class ProtocolClient {
14
17
  protected async processMessage<T>(
15
18
  message: unknown,
16
19
  protocol: string,
17
- options?: { signal?: AbortSignal }
20
+ options?: { signal?: AbortSignal; correlationId?: string }
18
21
  ): Promise<T> {
19
- const stream = await this.peerNetwork.connect(
20
- this.peerId,
21
- protocol,
22
- { signal: options?.signal }
23
- ) as unknown as Libp2pStream;
22
+ const peer = this.peerId.toString();
23
+ const cid = options?.correlationId;
24
+ log('dial peer=%s protocol=%s%s', peer, protocol, cid ? ` cid=${cid}` : '');
25
+ const t0 = Date.now();
26
+
27
+ let stream: Libp2pStream;
28
+ try {
29
+ stream = await this.peerNetwork.connect(
30
+ this.peerId,
31
+ protocol,
32
+ { signal: options?.signal }
33
+ ) as unknown as Libp2pStream;
34
+ } catch (err) {
35
+ log('dial:fail peer=%s protocol=%s ms=%d%s', peer, protocol, Date.now() - t0, cid ? ` cid=${cid}` : '');
36
+ throw err;
37
+ }
38
+ log('dial:ok peer=%s ms=%d%s', peer, Date.now() - t0, cid ? ` cid=${cid}` : '');
24
39
 
25
40
  try {
26
41
  // Send the request using length-prefixed encoding
@@ -33,11 +48,16 @@ export class ProtocolClient {
33
48
  }
34
49
 
35
50
  // Read the response from the stream (which is now directly AsyncIterable)
51
+ let firstByte = true;
36
52
  const source = pipe(
37
53
  stream,
38
54
  lpDecode,
39
55
  async function* (source) {
40
56
  for await (const data of source) {
57
+ if (firstByte) {
58
+ log('first-byte peer=%s ms=%d%s', peer, Date.now() - t0, cid ? ` cid=${cid}` : '');
59
+ firstByte = false;
60
+ }
41
61
  const decoded = new TextDecoder().decode(data.subarray());
42
62
  const parsed = JSON.parse(decoded);
43
63
  yield parsed;
@@ -45,7 +65,9 @@ export class ProtocolClient {
45
65
  }
46
66
  ) as AsyncIterable<T>;
47
67
 
48
- return await first(() => source, () => { throw new Error('No response received') });
68
+ const result = await first(() => source, () => { throw new Error('No response received') });
69
+ log('response peer=%s protocol=%s ms=%d%s', peer, protocol, Date.now() - t0, cid ? ` cid=${cid}` : '');
70
+ return result;
49
71
  } finally {
50
72
  await stream.close();
51
73
  }
@@ -44,6 +44,15 @@ export class RepoClient extends ProtocolClient implements IRepo {
44
44
  );
45
45
  }
46
46
 
47
+ private extractCorrelationId(operations: RepoMessage['operations']): string | undefined {
48
+ const op = operations[0];
49
+ if (!op) return undefined;
50
+ if ('pend' in op) return op.pend.actionId;
51
+ if ('commit' in op) return op.commit.actionId;
52
+ if ('cancel' in op) return op.cancel.actionRef.actionId;
53
+ return undefined;
54
+ }
55
+
47
56
  private async processRepoMessage<T>(
48
57
  operations: RepoMessage['operations'],
49
58
  options: MessageOptions,
@@ -53,6 +62,7 @@ export class RepoClient extends ProtocolClient implements IRepo {
53
62
  operations,
54
63
  expiration: options.expiration,
55
64
  };
65
+ const correlationId = this.extractCorrelationId(operations);
56
66
  const deadline = options.expiration ?? (Date.now() + 30_000)
57
67
  const msLeft = Math.max(1, deadline - Date.now())
58
68
  const withTimeout = async <U>(fn: () => Promise<U>): Promise<U> => {
@@ -63,7 +73,7 @@ export class RepoClient extends ProtocolClient implements IRepo {
63
73
  }
64
74
  let response: any
65
75
  const preferred = (this.protocolPrefix ?? '/db-p2p') + '/repo/1.0.0'
66
- response = await withTimeout(() => super.processMessage<any>(message, preferred, { signal: options?.signal }))
76
+ response = await withTimeout(() => super.processMessage<any>(message, preferred, { signal: options?.signal, correlationId }))
67
77
 
68
78
  if (response?.redirect?.peers?.length) {
69
79
  if (hop >= 2) {
@@ -75,11 +85,11 @@ export class RepoClient extends ProtocolClient implements IRepo {
75
85
  if (next.id === currentIdStr) {
76
86
  throw new Error('Redirect loop detected in RepoClient (same peer)')
77
87
  }
78
- // cache hint
79
- this.recordCoordinatorForOpsIfSupported(operations, nextId)
80
- // single-hop retry against target peer using repo protocol
81
- const nextClient = RepoClient.create(nextId, this.peerNetwork, this.protocolPrefix)
82
- return await nextClient.processRepoMessage<T>(operations, options, hop + 1)
88
+ // cache hint
89
+ this.recordCoordinatorForOpsIfSupported(operations, nextId)
90
+ // single-hop retry against target peer using repo protocol
91
+ const nextClient = RepoClient.create(nextId, this.peerNetwork, this.protocolPrefix)
92
+ return await nextClient.processRepoMessage<T>(operations, options, hop + 1)
83
93
  }
84
94
  return response as T;
85
95
  }
@@ -97,6 +107,10 @@ export class RepoClient extends ProtocolClient implements IRepo {
97
107
  if ('commit' in op) {
98
108
  return new TextEncoder().encode(op.commit.tailId);
99
109
  }
110
+ if ('cancel' in op) {
111
+ const id = op.cancel.actionRef.blockIds[0];
112
+ return id ? new TextEncoder().encode(id) : undefined;
113
+ }
100
114
  return undefined;
101
115
  }
102
116
 
@@ -5,9 +5,11 @@ import { sha256 } from "multiformats/hashes/sha2";
5
5
  import { ClusterClient } from "../cluster/client.js";
6
6
  import { Pending } from "@optimystic/db-core";
7
7
  import type { PeerId } from "@libp2p/interface";
8
- import { createLogger } from '../logger.js'
8
+ import { createLogger, verbose } from '../logger.js'
9
9
  import type { ClusterLogPeerOutcome } from './types.js'
10
10
  import type { FretService } from "p2p-fret";
11
+ import type { IPeerReputation } from "../reputation/types.js";
12
+ import { PenaltyReason } from "../reputation/types.js";
11
13
 
12
14
  const log = createLogger('cluster')
13
15
 
@@ -49,7 +51,8 @@ export class ClusterCoordinator {
49
51
  peerId: PeerId;
50
52
  wasTransactionExecuted?: (messageHash: string) => boolean;
51
53
  },
52
- private readonly fretService?: FretService
54
+ private readonly fretService?: FretService,
55
+ private readonly reputation?: IPeerReputation
53
56
  ) { }
54
57
 
55
58
  /**
@@ -248,6 +251,28 @@ export class ClusterCoordinator {
248
251
  throw new Error(`Failed to get super-majority: ${approvalCount}/${peerCount} approvals (needed ${superMajority}, ${rejectionCount} rejections)`);
249
252
  }
250
253
 
254
+ // Mark as disputed when minority rejections exist but super-majority approves
255
+ if (rejectionCount > 0 && approvalCount >= superMajority) {
256
+ const rejectingPeers: string[] = [];
257
+ const rejectReasons: { [peerId: string]: string } = {};
258
+ for (const [peerId, sig] of Object.entries(promises)) {
259
+ if (sig.type === 'reject') {
260
+ rejectingPeers.push(peerId);
261
+ rejectReasons[peerId] = sig.rejectReason ?? 'unknown';
262
+ }
263
+ }
264
+ promised.record.disputed = true;
265
+ promised.record.disputeEvidence = { rejectingPeers, rejectReasons };
266
+ log('cluster-tx:disputed', {
267
+ messageHash: record.messageHash,
268
+ rejectingPeers,
269
+ rejectReasons,
270
+ approvalCount,
271
+ rejectionCount,
272
+ peerCount
273
+ });
274
+ }
275
+
251
276
  return await this.commitTransaction(promised.record);
252
277
  }
253
278
 
@@ -301,6 +326,13 @@ export class ClusterCoordinator {
301
326
  private async collectPromises(peers: ClusterPeers, record: ClusterRecord): Promise<{ record: ClusterRecord }> {
302
327
  const peerIds = Object.keys(peers);
303
328
  const summary: ClusterLogPeerOutcome[] = [];
329
+ if (verbose) {
330
+ const peerDetail = peerIds.map(id => ({
331
+ id: id.substring(0, 12),
332
+ addrs: peers[id]?.multiaddrs?.length ?? 0
333
+ }));
334
+ log('cluster-tx:promise-peers', { messageHash: record.messageHash, peers: peerDetail });
335
+ }
304
336
  // For each peer, create a client and request a promise
305
337
  const promiseRequests = peerIds.map(peerIdStr => {
306
338
  const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
@@ -327,6 +359,7 @@ export class ClusterCoordinator {
327
359
  const peerIdStr = peerIds[idx]!;
328
360
  log('cluster-tx:promise-response', { messageHash: record.messageHash, peerId: peerIdStr, success: false, error: err });
329
361
  summary.push({ peerId: peerIdStr, success: false, error: err instanceof Error ? err.message : String(err) });
362
+ this.reputation?.reportPeer(peerIdStr, PenaltyReason.ConsensusTimeout, `promise:${record.messageHash}`);
330
363
  return null;
331
364
  })));
332
365
  const successes = summary.filter(entry => entry.success).map(entry => entry.peerId);
@@ -390,6 +423,13 @@ export class ClusterCoordinator {
390
423
  // For each peer, create a client and send the commit
391
424
  const peerIds = Object.keys(record.peers);
392
425
  const summary: ClusterLogPeerOutcome[] = [];
426
+ if (verbose) {
427
+ const peerDetail = peerIds.map(id => ({
428
+ id: id.substring(0, 12),
429
+ addrs: record.peers[id]?.multiaddrs?.length ?? 0
430
+ }));
431
+ log('cluster-tx:commit-peers', { messageHash: record.messageHash, peers: peerDetail });
432
+ }
393
433
  // Send the record with promises to all peers
394
434
  // Each peer will add its own commit signature
395
435
  const commitPayload = {
@@ -414,6 +454,7 @@ export class ClusterCoordinator {
414
454
  const peerIdStr = peerIds[idx]!;
415
455
  log('cluster-tx:commit-response', { messageHash: record.messageHash, peerId: peerIdStr, success: false, error: err });
416
456
  summary.push({ peerId: peerIdStr, success: false, error: err instanceof Error ? err.message : String(err) });
457
+ this.reputation?.reportPeer(peerIdStr, PenaltyReason.ConsensusTimeout, `commit:${record.messageHash}`);
417
458
  return null;
418
459
  })));
419
460
  const commitSuccesses = summary.filter(entry => entry.success).map(entry => entry.peerId);
@@ -1,10 +1,12 @@
1
1
  import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev } from "@optimystic/db-core";
2
+ import { LruMap } from "@optimystic/db-core";
2
3
  import { ClusterCoordinator } from "./cluster-coordinator.js";
3
4
  import type { ClusterClient } from "../cluster/client.js";
4
5
  import type { PeerId } from "@libp2p/interface";
5
6
  import { peerIdFromString } from "@libp2p/peer-id";
6
7
  import type { FretService } from "p2p-fret";
7
8
  import { createLogger } from '../logger.js';
9
+ import type { IPeerReputation } from "../reputation/types.js";
8
10
 
9
11
  const log = createLogger('coordinator-repo');
10
12
 
@@ -37,7 +39,8 @@ export function coordinatorRepo(
37
39
  keyNetwork: IKeyNetwork,
38
40
  createClusterClient: (peerId: PeerId) => ClusterClient,
39
41
  cfg?: Partial<ClusterConsensusConfig> & { clusterSize?: number },
40
- fretService?: FretService
42
+ fretService?: FretService,
43
+ reputation?: IPeerReputation
41
44
  ): (components: CoordinatorRepoComponents) => CoordinatorRepo {
42
45
  return (components: CoordinatorRepoComponents) => new CoordinatorRepo(
43
46
  keyNetwork,
@@ -47,7 +50,8 @@ export function coordinatorRepo(
47
50
  components.localCluster,
48
51
  components.localPeerId,
49
52
  fretService,
50
- components.clusterLatestCallback
53
+ components.clusterLatestCallback,
54
+ reputation
51
55
  );
52
56
  }
53
57
 
@@ -55,6 +59,9 @@ export function coordinatorRepo(
55
59
  export class CoordinatorRepo implements IRepo {
56
60
  private coordinator: ClusterCoordinator;
57
61
  private readonly DEFAULT_TIMEOUT = 30000; // 30 seconds default timeout
62
+ private readonly localPeerId?: PeerId;
63
+ private readonly responsibilityCache = new LruMap<string, { inCluster: boolean, expires: number }>(1000);
64
+ private static readonly RESPONSIBILITY_TTL_MS = 60_000;
58
65
 
59
66
  constructor(
60
67
  readonly keyNetwork: IKeyNetwork,
@@ -64,8 +71,10 @@ export class CoordinatorRepo implements IRepo {
64
71
  localCluster?: LocalClusterWithExecutionTracking,
65
72
  localPeerId?: PeerId,
66
73
  fretService?: FretService,
67
- private readonly clusterLatestCallback?: ClusterLatestCallback
74
+ private readonly clusterLatestCallback?: ClusterLatestCallback,
75
+ reputation?: IPeerReputation
68
76
  ) {
77
+ this.localPeerId = localPeerId;
69
78
  const policy: ClusterConsensusConfig & { clusterSize: number } = {
70
79
  clusterSize: cfg?.clusterSize ?? 10,
71
80
  superMajorityThreshold: cfg?.superMajorityThreshold ?? 0.75,
@@ -80,10 +89,64 @@ export class CoordinatorRepo implements IRepo {
80
89
  peerId: localPeerId,
81
90
  wasTransactionExecuted: localCluster.wasTransactionExecuted?.bind(localCluster)
82
91
  } : undefined;
83
- this.coordinator = new ClusterCoordinator(keyNetwork, createClusterClient, policy, localClusterRef, fretService);
92
+ this.coordinator = new ClusterCoordinator(keyNetwork, createClusterClient, policy, localClusterRef, fretService, reputation);
93
+ }
94
+
95
+ /**
96
+ * Check if this node is in the cluster for a given block.
97
+ * Uses findCluster membership — in the real network layer, self is always
98
+ * included in the cohort when this node is responsible. This serves as a
99
+ * defense-in-depth guard for requests that arrive at the wrong node.
100
+ * Returns true if localPeerId is not set (backward compat for single-node/test setups).
101
+ */
102
+ private async isResponsibleForBlock(blockId: BlockId): Promise<boolean> {
103
+ if (!this.localPeerId) return true;
104
+
105
+ const cached = this.responsibilityCache.get(blockId);
106
+ if (cached && cached.expires > Date.now()) {
107
+ return cached.inCluster;
108
+ }
109
+
110
+ const blockIdBytes = new TextEncoder().encode(blockId);
111
+ let inCluster: boolean;
112
+ try {
113
+ const peers = await this.keyNetwork.findCluster(blockIdBytes);
114
+ inCluster = this.localPeerId.toString() in peers;
115
+ } catch (err) {
116
+ log('proximity:check-error', { blockId, error: (err as Error).message });
117
+ // On failure, assume responsible to avoid false rejections
118
+ return true;
119
+ }
120
+
121
+ this.responsibilityCache.set(blockId, { inCluster, expires: Date.now() + CoordinatorRepo.RESPONSIBILITY_TTL_MS });
122
+ log('proximity:checked', { blockId, inCluster });
123
+ return inCluster;
124
+ }
125
+
126
+ /**
127
+ * Verify this node is responsible for all given block IDs. Throws if not.
128
+ */
129
+ private async verifyResponsibility(blockIds: BlockId[]): Promise<void> {
130
+ const notResponsible: BlockId[] = [];
131
+ for (const blockId of blockIds) {
132
+ if (!await this.isResponsibleForBlock(blockId)) {
133
+ notResponsible.push(blockId);
134
+ }
135
+ }
136
+ if (notResponsible.length > 0) {
137
+ log('proximity:rejected', { blockIds: notResponsible });
138
+ throw new Error(`Not responsible for block(s): ${notResponsible.join(', ')}`);
139
+ }
84
140
  }
85
141
 
86
142
  async get(blockGets: BlockGets, options?: MessageOptions): Promise<GetBlockResults> {
143
+ // Soft proximity check — warn but still serve reads for graceful degradation
144
+ for (const blockId of blockGets.blockIds) {
145
+ if (!await this.isResponsibleForBlock(blockId)) {
146
+ log('proximity:get-warning', { blockId, msg: 'serving read for non-responsible block' });
147
+ }
148
+ }
149
+
87
150
  // First try local storage
88
151
  const localResult = await this.storageRepo.get(blockGets, options);
89
152
 
@@ -166,6 +229,7 @@ export class CoordinatorRepo implements IRepo {
166
229
 
167
230
  async pend(request: PendRequest, options?: MessageOptions): Promise<PendResult> {
168
231
  const allBlockIds = Object.keys(request.transforms);
232
+ await this.verifyResponsibility(allBlockIds);
169
233
  const coordinatingBlockIds = (options as any)?.coordinatingBlockIds ?? allBlockIds;
170
234
 
171
235
  const peerCount = await this.coordinator.getClusterSize(coordinatingBlockIds[0]!);
@@ -209,10 +273,8 @@ export class CoordinatorRepo implements IRepo {
209
273
  }
210
274
 
211
275
  async cancel(actionRef: ActionBlocks, options?: MessageOptions): Promise<void> {
212
- // TODO: Verify that we are a proximate node for all block IDs in the request
213
-
214
- // Extract all block IDs affected by this cancel operation
215
276
  const blockIds = actionRef.blockIds;
277
+ await this.verifyResponsibility(blockIds);
216
278
 
217
279
  // Create a message for this cancel operation with timeout
218
280
  const message: RepoMessage = {
@@ -242,6 +304,7 @@ export class CoordinatorRepo implements IRepo {
242
304
 
243
305
  async commit(request: CommitRequest, options?: MessageOptions): Promise<CommitResult> {
244
306
  const blockIds = request.blockIds;
307
+ await this.verifyResponsibility(blockIds);
245
308
 
246
309
  const peerCount = await this.coordinator.getClusterSize(blockIds[0]!);
247
310
  if (peerCount <= 1) {
@@ -13,5 +13,3 @@ export function encodePeers(peers: Array<{ id: string, addrs: string[] }>): Redi
13
13
  }
14
14
  }
15
15
  }
16
-
17
-