@optimystic/db-p2p 0.3.0 → 0.9.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 (55) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  2. package/dist/src/cluster/block-transfer-service.js +4 -1
  3. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  4. package/dist/src/cluster/block-transfer.d.ts +2 -16
  5. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  6. package/dist/src/cluster/block-transfer.js +68 -71
  7. package/dist/src/cluster/block-transfer.js.map +1 -1
  8. package/dist/src/cluster/client.d.ts.map +1 -1
  9. package/dist/src/cluster/client.js +7 -1
  10. package/dist/src/cluster/client.js.map +1 -1
  11. package/dist/src/cluster/cluster-repo.d.ts +11 -1
  12. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  13. package/dist/src/cluster/cluster-repo.js +53 -18
  14. package/dist/src/cluster/cluster-repo.js.map +1 -1
  15. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  16. package/dist/src/cluster/rebalance-monitor.js +3 -5
  17. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  18. package/dist/src/dispute/dispute-service.d.ts +2 -0
  19. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  20. package/dist/src/dispute/dispute-service.js +8 -2
  21. package/dist/src/dispute/dispute-service.js.map +1 -1
  22. package/dist/src/index.d.ts +2 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +2 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  27. package/dist/src/libp2p-key-network.js +5 -3
  28. package/dist/src/libp2p-key-network.js.map +1 -1
  29. package/dist/src/libp2p-node-base.js +1 -1
  30. package/dist/src/libp2p-node-base.js.map +1 -1
  31. package/dist/src/repo/cluster-coordinator.d.ts +2 -0
  32. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  33. package/dist/src/repo/cluster-coordinator.js +39 -17
  34. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  35. package/dist/src/repo/coordinator-repo.d.ts +2 -2
  36. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  37. package/dist/src/repo/coordinator-repo.js +6 -6
  38. package/dist/src/repo/coordinator-repo.js.map +1 -1
  39. package/dist/src/sync/service.d.ts +7 -7
  40. package/dist/src/sync/service.d.ts.map +1 -1
  41. package/dist/src/sync/service.js +42 -73
  42. package/dist/src/sync/service.js.map +1 -1
  43. package/package.json +5 -4
  44. package/src/cluster/block-transfer-service.ts +4 -1
  45. package/src/cluster/block-transfer.ts +80 -99
  46. package/src/cluster/client.ts +6 -1
  47. package/src/cluster/cluster-repo.ts +72 -17
  48. package/src/cluster/rebalance-monitor.ts +3 -5
  49. package/src/dispute/dispute-service.ts +9 -3
  50. package/src/index.ts +2 -0
  51. package/src/libp2p-key-network.ts +5 -3
  52. package/src/libp2p-node-base.ts +1 -1
  53. package/src/repo/cluster-coordinator.ts +44 -18
  54. package/src/repo/coordinator-repo.ts +8 -8
  55. package/src/sync/service.ts +62 -96
@@ -1,28 +1,13 @@
1
- import type { BlockId, IRepo } from '@optimystic/db-core';
2
- import type { PeerId, IPeerNetwork } from '@optimystic/db-core';
1
+ import type { IRepo, IPeerNetwork } from '@optimystic/db-core';
3
2
  import { peerIdFromString } from '@libp2p/peer-id';
4
3
  import type { PartitionDetector } from './partition-detector.js';
5
4
  import type { RestorationCoordinator } from '../storage/restoration-coordinator-v2.js';
6
- import { BlockTransferClient, type BlockTransferRequest, type BlockTransferResponse } from './block-transfer-service.js';
5
+ import { BlockTransferClient } from './block-transfer-service.js';
6
+ import type { RebalanceEvent } from './rebalance-monitor.js';
7
7
  import { createLogger } from '../logger.js';
8
8
 
9
9
  const log = createLogger('block-transfer');
10
10
 
11
- /**
12
- * Rebalance event describing gained/lost block responsibilities.
13
- * Matches the RebalanceMonitor spec from the sibling ticket.
14
- */
15
- export interface RebalanceEvent {
16
- /** Block IDs this node has gained responsibility for */
17
- gained: string[];
18
- /** Block IDs this node has lost responsibility for */
19
- lost: string[];
20
- /** Peers that are now closer for the lost blocks: blockId → peerId[] */
21
- newOwners: Map<string, string[]>;
22
- /** Timestamp of the topology change that triggered this */
23
- triggeredAt: number;
24
- }
25
-
26
11
  export interface BlockTransferConfig {
27
12
  /** Max concurrent transfers. Default: 4 */
28
13
  maxConcurrency?: number;
@@ -34,11 +19,6 @@ export interface BlockTransferConfig {
34
19
  enablePush?: boolean;
35
20
  }
36
21
 
37
- interface TransferTask {
38
- blockId: string;
39
- attempt: number;
40
- }
41
-
42
22
  /**
43
23
  * Coordinates block transfers in response to rebalance events.
44
24
  *
@@ -84,11 +64,9 @@ export class BlockTransferCoordinator {
84
64
  const succeeded: string[] = [];
85
65
  const failed: string[] = [];
86
66
 
87
- const tasks = blockIds
88
- .filter(id => !this.inFlight.has(`pull:${id}`))
89
- .map(id => ({ blockId: id, attempt: 0 }));
67
+ const ids = blockIds.filter(id => !this.inFlight.has(`pull:${id}`));
90
68
 
91
- await Promise.all(tasks.map(task => this.executePull(task, succeeded, failed)));
69
+ await Promise.all(ids.map(id => this.executePull(id, succeeded, failed)));
92
70
 
93
71
  return { succeeded, failed };
94
72
  }
@@ -111,11 +89,9 @@ export class BlockTransferCoordinator {
111
89
  const succeeded: string[] = [];
112
90
  const failed: string[] = [];
113
91
 
114
- const tasks = blockIds
115
- .filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id))
116
- .map(id => ({ blockId: id, attempt: 0 }));
92
+ const ids = blockIds.filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id));
117
93
 
118
- await Promise.all(tasks.map(task => this.executePush(task, newOwners, succeeded, failed)));
94
+ await Promise.all(ids.map(id => this.executePush(id, newOwners, succeeded, failed)));
119
95
 
120
96
  return { succeeded, failed };
121
97
  }
@@ -139,37 +115,40 @@ export class BlockTransferCoordinator {
139
115
  }
140
116
 
141
117
  private async executePull(
142
- task: TransferTask,
118
+ blockId: string,
143
119
  succeeded: string[],
144
120
  failed: string[]
145
121
  ): Promise<void> {
146
- const key = `pull:${task.blockId}`;
122
+ const key = `pull:${blockId}`;
147
123
  if (this.inFlight.has(key)) return;
148
124
  this.inFlight.add(key);
149
125
 
150
126
  try {
151
- await this.acquireSemaphore();
152
- try {
153
- const archive = await this.withTimeout(
154
- this.restorationCoordinator.restore(task.blockId),
155
- this.transferTimeoutMs
156
- );
127
+ for (let attempt = 0; ; attempt++) {
128
+ await this.acquireSemaphore();
129
+ let archive: Awaited<ReturnType<RestorationCoordinator['restore']>>;
130
+ try {
131
+ archive = await this.withTimeout(
132
+ this.restorationCoordinator.restore(blockId),
133
+ this.transferTimeoutMs
134
+ );
135
+ } finally {
136
+ this.releaseSemaphore();
137
+ }
157
138
 
158
139
  if (archive) {
159
- log('pull:ok block=%s', task.blockId);
160
- succeeded.push(task.blockId);
161
- } else if (task.attempt < this.maxRetries) {
162
- log('pull:retry block=%s attempt=%d', task.blockId, task.attempt + 1);
163
- this.inFlight.delete(key);
164
- await this.delay(this.backoffMs(task.attempt));
165
- await this.executePull({ ...task, attempt: task.attempt + 1 }, succeeded, failed);
140
+ log('pull:ok block=%s', blockId);
141
+ succeeded.push(blockId);
166
142
  return;
167
- } else {
168
- log('pull:failed block=%s', task.blockId);
169
- failed.push(task.blockId);
170
143
  }
171
- } finally {
172
- this.releaseSemaphore();
144
+ if (attempt < this.maxRetries) {
145
+ log('pull:retry block=%s attempt=%d', blockId, attempt + 1);
146
+ await this.delay(this.backoffMs(attempt));
147
+ continue;
148
+ }
149
+ log('pull:failed block=%s', blockId);
150
+ failed.push(blockId);
151
+ return;
173
152
  }
174
153
  } finally {
175
154
  this.inFlight.delete(key);
@@ -177,71 +156,73 @@ export class BlockTransferCoordinator {
177
156
  }
178
157
 
179
158
  private async executePush(
180
- task: TransferTask,
159
+ blockId: string,
181
160
  newOwners: Map<string, string[]>,
182
161
  succeeded: string[],
183
162
  failed: string[]
184
163
  ): Promise<void> {
185
- const key = `push:${task.blockId}`;
164
+ const key = `push:${blockId}`;
186
165
  if (this.inFlight.has(key)) return;
187
166
  this.inFlight.add(key);
188
167
 
189
168
  try {
190
- await this.acquireSemaphore();
191
- try {
192
- const owners = newOwners.get(task.blockId);
193
- if (!owners || owners.length === 0) {
194
- failed.push(task.blockId);
195
- return;
196
- }
197
-
198
- // Read block data from local storage
199
- const result = await this.repo.get({ blockIds: [task.blockId] });
200
- const blockResult = result[task.blockId];
201
- if (!blockResult?.block) {
202
- log('push:no-local-data block=%s', task.blockId);
203
- failed.push(task.blockId);
204
- return;
205
- }
169
+ for (let attempt = 0; ; attempt++) {
170
+ await this.acquireSemaphore();
171
+ let pushed = false;
172
+ try {
173
+ const owners = newOwners.get(blockId);
174
+ if (!owners || owners.length === 0) {
175
+ failed.push(blockId);
176
+ return;
177
+ }
206
178
 
207
- const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
179
+ // Read block data from local storage
180
+ const result = await this.repo.get({ blockIds: [blockId] });
181
+ const blockResult = result[blockId];
182
+ if (!blockResult?.block) {
183
+ log('push:no-local-data block=%s', blockId);
184
+ failed.push(blockId);
185
+ return;
186
+ }
208
187
 
209
- // Push to at least one new owner
210
- let pushed = false;
211
- for (const ownerPeerIdStr of owners) {
212
- try {
213
- const peerId = peerIdFromString(ownerPeerIdStr);
214
- const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
215
- const response = await this.withTimeout(
216
- client.pushBlocks([task.blockId], [blockData]),
217
- this.transferTimeoutMs
218
- );
219
-
220
- if (response && !response.missing.includes(task.blockId)) {
221
- pushed = true;
222
- log('push:ok block=%s peer=%s', task.blockId, ownerPeerIdStr);
223
- break;
188
+ const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
189
+
190
+ // Push to at least one new owner
191
+ for (const ownerPeerIdStr of owners) {
192
+ try {
193
+ const peerId = peerIdFromString(ownerPeerIdStr);
194
+ const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
195
+ const response = await this.withTimeout(
196
+ client.pushBlocks([blockId], [blockData]),
197
+ this.transferTimeoutMs
198
+ );
199
+
200
+ if (response && !response.missing.includes(blockId)) {
201
+ pushed = true;
202
+ log('push:ok block=%s peer=%s', blockId, ownerPeerIdStr);
203
+ break;
204
+ }
205
+ } catch (err) {
206
+ log('push:peer-error block=%s peer=%s err=%s',
207
+ blockId, ownerPeerIdStr, (err as Error).message);
224
208
  }
225
- } catch (err) {
226
- log('push:peer-error block=%s peer=%s err=%s',
227
- task.blockId, ownerPeerIdStr, (err as Error).message);
228
209
  }
210
+ } finally {
211
+ this.releaseSemaphore();
229
212
  }
230
213
 
231
214
  if (pushed) {
232
- succeeded.push(task.blockId);
233
- } else if (task.attempt < this.maxRetries) {
234
- log('push:retry block=%s attempt=%d', task.blockId, task.attempt + 1);
235
- this.inFlight.delete(key);
236
- await this.delay(this.backoffMs(task.attempt));
237
- await this.executePush({ ...task, attempt: task.attempt + 1 }, newOwners, succeeded, failed);
215
+ succeeded.push(blockId);
238
216
  return;
239
- } else {
240
- log('push:failed block=%s', task.blockId);
241
- failed.push(task.blockId);
242
217
  }
243
- } finally {
244
- this.releaseSemaphore();
218
+ if (attempt < this.maxRetries) {
219
+ log('push:retry block=%s attempt=%d', blockId, attempt + 1);
220
+ await this.delay(this.backoffMs(attempt));
221
+ continue;
222
+ }
223
+ log('push:failed block=%s', blockId);
224
+ failed.push(blockId);
225
+ return;
245
226
  }
246
227
  } finally {
247
228
  this.inFlight.delete(key);
@@ -23,7 +23,12 @@ export class ClusterClient extends ProtocolClient implements ICluster {
23
23
  response = await this.processMessage<any>(message, preferred)
24
24
  } catch (err) {
25
25
  if (preferred !== '/db-p2p/cluster/1.0.0') {
26
- response = await this.processMessage<any>(message, '/db-p2p/cluster/1.0.0')
26
+ try {
27
+ response = await this.processMessage<any>(message, '/db-p2p/cluster/1.0.0')
28
+ } catch (fallbackErr) {
29
+ // Throw original error - fallback protocol is likely not registered
30
+ throw err
31
+ }
27
32
  } else {
28
33
  throw err
29
34
  }
@@ -237,15 +237,8 @@ export class ClusterMember implements ICluster {
237
237
  log('cluster-member:action-consensus', {
238
238
  messageHash: record.messageHash
239
239
  });
240
- // If the incoming record already had our commit, we already executed
241
- // (idempotency for duplicate consensus messages)
242
- if (inboundPhase !== 'commit') {
243
- await this.handleConsensus(currentRecord);
244
- } else {
245
- log('cluster-member:consensus-skip-already-committed', {
246
- messageHash: record.messageHash
247
- });
248
- }
240
+ // handleConsensus has its own idempotency guard via executedTransactions
241
+ await this.handleConsensus(currentRecord);
249
242
  // Don't call clearTransaction here - it happens in handleConsensus
250
243
  shouldPersist = false;
251
244
  break;
@@ -306,7 +299,8 @@ export class ClusterMember implements ICluster {
306
299
  }
307
300
 
308
301
  /**
309
- * Merges two records, validating that non-signature fields match
302
+ * Merges two records, validating that non-signature fields match.
303
+ * Detects equivocation (same peer changing vote type) and applies penalties.
310
304
  */
311
305
  private async mergeRecords(existing: ClusterRecord, incoming: ClusterRecord): Promise<ClusterRecord> {
312
306
  log('cluster-member:merge-records', {
@@ -327,14 +321,64 @@ export class ClusterMember implements ICluster {
327
321
  throw new Error('Peers mismatch');
328
322
  }
329
323
 
330
- // Merge signatures, keeping the most recent valid ones
324
+ // Merge signatures with equivocation detection
325
+ const mergedPromises = this.detectEquivocation(
326
+ existing.promises, incoming.promises, 'promise', existing.messageHash
327
+ );
328
+ const mergedCommits = this.detectEquivocation(
329
+ existing.commits, incoming.commits, 'commit', existing.messageHash
330
+ );
331
+
331
332
  return {
332
333
  ...existing,
333
- promises: { ...existing.promises, ...incoming.promises },
334
- commits: { ...existing.commits, ...incoming.commits }
334
+ promises: mergedPromises,
335
+ commits: mergedCommits
335
336
  };
336
337
  }
337
338
 
339
+ /**
340
+ * Compares existing vs incoming signatures for the same peers.
341
+ * If a peer's vote type changed (approve↔reject), that's equivocation:
342
+ * report a penalty and keep the first-seen signature.
343
+ * New peers are accepted normally.
344
+ */
345
+ private detectEquivocation(
346
+ existing: Record<string, Signature>,
347
+ incoming: Record<string, Signature>,
348
+ phase: 'promise' | 'commit',
349
+ messageHash: string
350
+ ): Record<string, Signature> {
351
+ const merged = { ...existing };
352
+
353
+ for (const [peerId, incomingSig] of Object.entries(incoming)) {
354
+ const existingSig = existing[peerId];
355
+ if (existingSig) {
356
+ if (existingSig.type !== incomingSig.type) {
357
+ // Equivocation detected: peer changed their vote type
358
+ log('cluster-member:equivocation-detected', {
359
+ peerId,
360
+ phase,
361
+ messageHash,
362
+ existingType: existingSig.type,
363
+ incomingType: incomingSig.type
364
+ });
365
+ this.reputation?.reportPeer(
366
+ peerId,
367
+ PenaltyReason.Equivocation,
368
+ `${phase}:${messageHash}:${existingSig.type}->${incomingSig.type}`
369
+ );
370
+ // Keep first-seen signature — do not let the peer flip their vote
371
+ }
372
+ // Same type: keep existing (no-op, already in merged)
373
+ } else {
374
+ // New peer — accept normally
375
+ merged[peerId] = incomingSig;
376
+ }
377
+ }
378
+
379
+ return merged;
380
+ }
381
+
338
382
  private async validateRecord(record: ClusterRecord): Promise<void> {
339
383
  // Validate message hash matches the message content
340
384
  const expectedHash = await this.computeMessageHash(record.message);
@@ -356,7 +400,7 @@ export class ClusterMember implements ICluster {
356
400
  * Must match cluster-coordinator.ts createMessageHash().
357
401
  */
358
402
  private async computeMessageHash(message: RepoMessage): Promise<string> {
359
- const msgBytes = new TextEncoder().encode(JSON.stringify(message));
403
+ const msgBytes = new TextEncoder().encode(ClusterMember.canonicalJson(message));
360
404
  const hashBytes = await sha256.digest(msgBytes);
361
405
  return base58btc.encode(hashBytes.digest);
362
406
  }
@@ -386,14 +430,23 @@ export class ClusterMember implements ICluster {
386
430
  }
387
431
  }
388
432
 
433
+ /** Deterministic JSON: sorts object keys so hash is order-independent */
434
+ private static canonicalJson(value: unknown): string {
435
+ return JSON.stringify(value, (_, v) =>
436
+ v && typeof v === 'object' && !Array.isArray(v)
437
+ ? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
438
+ : v
439
+ );
440
+ }
441
+
389
442
  private async computePromiseHash(record: ClusterRecord): Promise<string> {
390
- const msgBytes = new TextEncoder().encode(record.messageHash + JSON.stringify(record.message));
443
+ const msgBytes = new TextEncoder().encode(record.messageHash + ClusterMember.canonicalJson(record.message));
391
444
  const hashBytes = await sha256.digest(msgBytes);
392
445
  return uint8ArrayToString(hashBytes.digest, 'base64url');
393
446
  }
394
447
 
395
448
  private async computeCommitHash(record: ClusterRecord): Promise<string> {
396
- const msgBytes = new TextEncoder().encode(record.messageHash + JSON.stringify(record.message) + JSON.stringify(record.promises));
449
+ const msgBytes = new TextEncoder().encode(record.messageHash + ClusterMember.canonicalJson(record.message) + ClusterMember.canonicalJson(record.promises));
397
450
  const hashBytes = await sha256.digest(msgBytes);
398
451
  return uint8ArrayToString(hashBytes.digest, 'base64url');
399
452
  }
@@ -414,7 +467,9 @@ export class ClusterMember implements ICluster {
414
467
  if (!peerInfo?.publicKey?.length) {
415
468
  throw new Error(`No public key for peer ${peerId}`);
416
469
  }
417
- const pubKey = publicKeyFromRaw(peerInfo.publicKey);
470
+ // publicKey is base64url-encoded string (JSON-serialization safe)
471
+ const keyBytes = uint8ArrayFromString(peerInfo.publicKey, 'base64url');
472
+ const pubKey = publicKeyFromRaw(keyBytes);
418
473
  const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
419
474
  const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
420
475
  return pubKey.verify(payload, sigBytes);
@@ -6,6 +6,7 @@ import type { ArachnodeFretAdapter, ArachnodeInfo } from '../storage/arachnode-f
6
6
  import { createLogger } from '../logger.js'
7
7
 
8
8
  const log = createLogger('rebalance-monitor')
9
+ const textEncoder = new TextEncoder()
9
10
 
10
11
  export interface RebalanceEvent {
11
12
  /** Block IDs this node has gained responsibility for */
@@ -164,7 +165,7 @@ export class RebalanceMonitor implements Startable {
164
165
  const newOwners = new Map<string, string[]>()
165
166
 
166
167
  for (const blockId of this.trackedBlocks) {
167
- const key = new TextEncoder().encode(blockId)
168
+ const key = textEncoder.encode(blockId)
168
169
  const coord = await hashKey(key)
169
170
 
170
171
  // Get the current cohort — assembleCohort returns peer IDs sorted by distance
@@ -217,9 +218,6 @@ export class RebalanceMonitor implements Startable {
217
218
  * Update ArachnodeInfo status through the fret adapter.
218
219
  */
219
220
  setStatus(status: ArachnodeInfo['status']): void {
220
- const current = this.deps.fretAdapter.getMyArachnodeInfo()
221
- if (current) {
222
- this.deps.fretAdapter.setArachnodeInfo({ ...current, status })
223
- }
221
+ this.deps.fretAdapter.setStatus(status)
224
222
  }
225
223
  }
@@ -63,6 +63,8 @@ export class DisputeService {
63
63
  private activeDisputes: Map<string, DisputeChallenge> = new Map();
64
64
  /** Resolved disputes (disputeId -> resolution) */
65
65
  private resolvedDisputes: Map<string, DisputeResolution> = new Map();
66
+ /** Challenges retained after resolution for status lookups */
67
+ private resolvedChallenges: Map<string, DisputeChallenge> = new Map();
66
68
  /** Track which transactions we've already disputed (prevent spam) */
67
69
  private disputedTransactions: Set<string> = new Set();
68
70
 
@@ -179,6 +181,7 @@ export class DisputeService {
179
181
  const votes = await this.collectVotes(challenge, arbitrators);
180
182
  const resolution = this.resolveDispute(challenge, votes);
181
183
 
184
+ this.resolvedChallenges.set(disputeId, challenge);
182
185
  this.activeDisputes.delete(disputeId);
183
186
  this.resolvedDisputes.set(disputeId, resolution);
184
187
 
@@ -434,11 +437,14 @@ export class DisputeService {
434
437
  private async verifyDisputeSignature(
435
438
  disputeId: string,
436
439
  signature: string,
437
- publicKey?: Uint8Array
440
+ publicKey?: string | Uint8Array
438
441
  ): Promise<boolean> {
439
442
  if (!publicKey?.length) return false;
440
443
  try {
441
- const pubKey = publicKeyFromRaw(publicKey);
444
+ const keyBytes = typeof publicKey === 'string'
445
+ ? uint8ArrayFromString(publicKey, 'base64url')
446
+ : publicKey;
447
+ const pubKey = publicKeyFromRaw(keyBytes);
442
448
  const payload = new TextEncoder().encode(disputeId);
443
449
  const sigBytes = uint8ArrayFromString(signature, 'base64url');
444
450
  return pubKey.verify(payload, sigBytes);
@@ -448,6 +454,6 @@ export class DisputeService {
448
454
  }
449
455
 
450
456
  private findChallengeForDispute(disputeId: string): DisputeChallenge | undefined {
451
- return this.activeDisputes.get(disputeId);
457
+ return this.activeDisputes.get(disputeId) ?? this.resolvedChallenges.get(disputeId);
452
458
  }
453
459
  }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ export * from "./cluster/client.js";
2
2
  export * from "./cluster/cluster-repo.js";
3
3
  export * from "./cluster/service.js";
4
4
  export * from "./cluster/rebalance-monitor.js";
5
+ export * from "./cluster/block-transfer.js";
6
+ export * from "./cluster/block-transfer-service.js";
5
7
  export * from "./protocol-client.js";
6
8
  export * from "./repo/client.js";
7
9
  export * from "./repo/cluster-coordinator.js";
@@ -1,5 +1,5 @@
1
1
  import type { AbortOptions, Libp2p, PeerId, Stream } from "@libp2p/interface";
2
- import { toString as u8ToString } from 'uint8arrays/to-string'
2
+ import { toString as u8ToString, fromString as u8FromString } from 'uint8arrays'
3
3
  import type { ClusterPeers, FindCoordinatorOptions, IKeyNetwork, IPeerNetwork } from "@optimystic/db-core";
4
4
  import { peerIdFromString } from '@libp2p/peer-id'
5
5
  import { multiaddr } from '@multiformats/multiaddr'
@@ -416,12 +416,14 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
416
416
 
417
417
  for (const idStr of ids) {
418
418
  if (idStr === this.libp2p.peerId.toString()) {
419
- peers[idStr] = { multiaddrs: this.libp2p.getMultiaddrs().map(ma => ma.toString()), publicKey: this.libp2p.peerId.publicKey?.raw ?? new Uint8Array() }
419
+ const raw = this.libp2p.peerId.publicKey?.raw ?? new Uint8Array()
420
+ peers[idStr] = { multiaddrs: this.libp2p.getMultiaddrs().map(ma => ma.toString()), publicKey: u8ToString(raw, 'base64url') }
420
421
  continue
421
422
  }
422
423
  const strings = connectedByPeer[idStr] ?? []
423
424
  const remotePeerId = peerIdFromString(idStr)
424
- peers[idStr] = { multiaddrs: this.parseMultiaddrs(strings), publicKey: remotePeerId.publicKey?.raw ?? new Uint8Array() }
425
+ const raw = remotePeerId.publicKey?.raw ?? new Uint8Array()
426
+ peers[idStr] = { multiaddrs: this.parseMultiaddrs(strings), publicKey: u8ToString(raw, 'base64url') }
425
427
  }
426
428
 
427
429
  this.log('findCluster:done key=%s ms=%d peers=%d',
@@ -315,7 +315,7 @@ export async function createLibp2pNodeBase(
315
315
  );
316
316
 
317
317
  // Create callback for querying cluster peers for their latest block revision
318
- const clusterLatestCallback: ClusterLatestCallback = async (peerId, blockId) => {
318
+ const clusterLatestCallback: ClusterLatestCallback = async (peerId, blockId, _context?) => {
319
319
  const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
320
320
  try {
321
321
  const response = await syncClient.requestBlock({ blockId, rev: undefined });
@@ -58,8 +58,17 @@ export class ClusterCoordinator {
58
58
  /**
59
59
  * Creates a base 58 BTC string hash for a message to uniquely identify a transaction
60
60
  */
61
+ /** Deterministic JSON: sorts object keys so hash is order-independent */
62
+ private static canonicalJson(value: unknown): string {
63
+ return JSON.stringify(value, (_, v) =>
64
+ v && typeof v === 'object' && !Array.isArray(v)
65
+ ? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
66
+ : v
67
+ );
68
+ }
69
+
61
70
  private async createMessageHash(message: RepoMessage): Promise<string> {
62
- const msgBytes = new TextEncoder().encode(JSON.stringify(message));
71
+ const msgBytes = new TextEncoder().encode(ClusterCoordinator.canonicalJson(message));
63
72
  const hashBytes = await sha256.digest(msgBytes);
64
73
  return base58btc.encode(hashBytes.digest);
65
74
  }
@@ -511,26 +520,43 @@ export class ClusterCoordinator {
511
520
  peerCount,
512
521
  threshold: this.cfg.simpleMajorityThreshold
513
522
  });
514
- // Simple majority proves commitment - we can return success
515
- // Notify local cluster with the final merged record so it can execute operations
516
- if (this.localCluster) {
517
- try {
518
- await this.localCluster.update(record);
519
- } catch (err) {
520
- // Local execution errors shouldn't fail the transaction since consensus was reached
521
- log('cluster-tx:local-execution-error', {
522
- messageHash: record.messageHash,
523
- error: err instanceof Error ? err.message : String(err)
524
- });
523
+ // Broadcast the merged record (with all commit signatures) to ALL peers
524
+ // so each peer can independently reach consensus and execute the operations.
525
+ // Without this, only the coordinator's local cluster executes — remote peers
526
+ // never see enough commits to reach consensus on their own.
527
+ const broadcastResults = await Promise.allSettled(
528
+ peerIds.map(peerIdStr => {
529
+ const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
530
+ return isLocal
531
+ ? this.localCluster!.update(record)
532
+ : this.createClusterClient(peerIdFromString(peerIdStr)).update(record).catch(err => {
533
+ log('cluster-tx:consensus-broadcast-error', { messageHash: record.messageHash, peerId: peerIdStr, error: (err as Error).message });
534
+ return null;
535
+ });
536
+ })
537
+ );
538
+
539
+ // Check for broadcast failures (excluding local)
540
+ const broadcastFailures: string[] = [];
541
+ broadcastResults.forEach((result, idx) => {
542
+ const peerId = peerIds[idx]!;
543
+ const isLocal = this.localCluster && peerId === this.localCluster.peerId.toString();
544
+ if (!isLocal && (result.status === 'rejected' || result.value === null)) {
545
+ broadcastFailures.push(peerId);
525
546
  }
547
+ });
548
+ if (broadcastFailures.length > 0) {
549
+ this.scheduleCommitRetry(record.messageHash, record, broadcastFailures);
550
+ } else {
551
+ this.clearRetry(record.messageHash);
526
552
  }
527
- }
528
-
529
- const missingPeers = commitFailures.map(entry => entry.peerId);
530
- if (missingPeers.length > 0) {
531
- this.scheduleCommitRetry(record.messageHash, record, missingPeers);
532
553
  } else {
533
- this.clearRetry(record.messageHash);
554
+ const missingPeers = commitFailures.map(entry => entry.peerId);
555
+ if (missingPeers.length > 0) {
556
+ this.scheduleCommitRetry(record.messageHash, record, missingPeers);
557
+ } else {
558
+ this.clearRetry(record.messageHash);
559
+ }
534
560
  }
535
561
  return record;
536
562
  }
@@ -1,4 +1,4 @@
1
- import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev } from "@optimystic/db-core";
1
+ import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext } from "@optimystic/db-core";
2
2
  import { LruMap } from "@optimystic/db-core";
3
3
  import { ClusterCoordinator } from "./cluster-coordinator.js";
4
4
  import type { ClusterClient } from "../cluster/client.js";
@@ -22,7 +22,7 @@ interface LocalClusterWithExecutionTracking extends ICluster {
22
22
  * Callback to query a cluster peer for their latest revision of a block.
23
23
  * Returns the peer's latest ActionRev if they have the block, undefined otherwise.
24
24
  */
25
- export type ClusterLatestCallback = (peerId: PeerId, blockId: BlockId) => Promise<ActionRev | undefined>;
25
+ export type ClusterLatestCallback = (peerId: PeerId, blockId: BlockId, context?: ActionContext) => Promise<ActionRev | undefined>;
26
26
 
27
27
  interface CoordinatorRepoComponents {
28
28
  storageRepo: IRepo;
@@ -159,7 +159,7 @@ export class CoordinatorRepo implements IRepo {
159
159
  // If block not found locally (no state), try cluster peers
160
160
  if (!localEntry?.state?.latest) {
161
161
  try {
162
- await this.fetchBlockFromCluster(blockId);
162
+ await this.fetchBlockFromCluster(blockId, blockGets.context);
163
163
  // Re-fetch after sync
164
164
  const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
165
165
  if (refreshed[blockId]) {
@@ -175,11 +175,11 @@ export class CoordinatorRepo implements IRepo {
175
175
  return localResult;
176
176
  }
177
177
 
178
- private async fetchBlockFromCluster(blockId: BlockId): Promise<void> {
178
+ private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext): Promise<void> {
179
179
  if (!this.clusterLatestCallback) return;
180
180
 
181
181
  // Query cluster for the block
182
- const clusterLatest = await this.queryClusterForLatest(blockId);
182
+ const clusterLatest = await this.queryClusterForLatest(blockId, context);
183
183
  if (clusterLatest) {
184
184
  // Found on cluster - trigger restoration to sync the block
185
185
  await this.storageRepo.get({ blockIds: [blockId], context: { committed: [clusterLatest], rev: clusterLatest.rev } });
@@ -190,7 +190,7 @@ export class CoordinatorRepo implements IRepo {
190
190
  /**
191
191
  * Query cluster peers to find the maximum latest revision for a block.
192
192
  */
193
- private async queryClusterForLatest(blockId: BlockId): Promise<ActionRev | undefined> {
193
+ private async queryClusterForLatest(blockId: BlockId, context?: ActionContext): Promise<ActionRev | undefined> {
194
194
  const blockIdBytes = new TextEncoder().encode(blockId);
195
195
  const peers = await this.keyNetwork.findCluster(blockIdBytes);
196
196
  if (!peers || Object.keys(peers).length === 0) {
@@ -207,11 +207,11 @@ export class CoordinatorRepo implements IRepo {
207
207
  new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), timeoutMs))
208
208
  ]);
209
209
 
210
- // Query peers in parallel for their latest revision (with 3 second timeout per peer)
210
+ // Query peers in parallel for their latest revision (with 1s timeout per peer)
211
211
  const latestResults = await Promise.allSettled(
212
212
  peerIds.map(peerIdStr => {
213
213
  const peerId = peerIdFromString(peerIdStr);
214
- return withTimeout(this.clusterLatestCallback!(peerId, blockId), 3000);
214
+ return withTimeout(this.clusterLatestCallback!(peerId, blockId, context), 1000);
215
215
  })
216
216
  );
217
217