@optimystic/db-p2p 0.2.3 → 0.7.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 +166 -0
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -0
  5. package/dist/src/cluster/block-transfer.d.ts +65 -0
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -0
  7. package/dist/src/cluster/block-transfer.js +208 -0
  8. package/dist/src/cluster/block-transfer.js.map +1 -0
  9. package/dist/src/cluster/cluster-repo.d.ts +23 -4
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +119 -39
  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 +157 -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 +83 -0
  28. package/dist/src/dispute/dispute-service.d.ts.map +1 -0
  29. package/dist/src/dispute/dispute-service.js +368 -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 +5 -0
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/dist/src/index.js +5 -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 +67 -13
  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 +20 -4
  80. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  81. package/dist/src/repo/coordinator-repo.js +67 -11
  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 +231 -0
  111. package/src/cluster/block-transfer.ts +265 -0
  112. package/src/cluster/cluster-repo.ts +148 -42
  113. package/src/cluster/rebalance-monitor.ts +223 -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 +456 -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 +5 -0
  122. package/src/libp2p-key-network.ts +120 -22
  123. package/src/libp2p-node-base.ts +78 -14
  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 +77 -14
  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
@@ -1,16 +1,19 @@
1
- import type { IRepo, ClusterRecord, Signature, RepoMessage, ITransactionValidator } from "@optimystic/db-core";
1
+ import type { IRepo, ClusterRecord, Signature, RepoMessage, ITransactionValidator, ClusterConsensusConfig } from "@optimystic/db-core";
2
2
  import type { ICluster } from "@optimystic/db-core";
3
3
  import type { IPeerNetwork } from "@optimystic/db-core";
4
4
  import { blockIdsForTransforms } from "@optimystic/db-core";
5
5
  import { ClusterClient } from "./client.js";
6
- import type { PeerId } from "@libp2p/interface";
6
+ import type { PeerId, PrivateKey } from "@libp2p/interface";
7
7
  import { peerIdFromString } from "@libp2p/peer-id";
8
+ import { publicKeyFromRaw } from "@libp2p/crypto/keys";
8
9
  import { sha256 } from "multiformats/hashes/sha2";
9
10
  import { base58btc } from "multiformats/bases/base58";
10
- import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
11
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
11
12
  import { createLogger } from '../logger.js'
12
13
  import type { PartitionDetector } from "./partition-detector.js";
13
14
  import type { FretService } from "p2p-fret";
15
+ import type { IPeerReputation } from "../reputation/types.js";
16
+ import { PenaltyReason } from "../reputation/types.js";
14
17
 
15
18
  const log = createLogger('cluster-member')
16
19
 
@@ -35,10 +38,13 @@ interface ClusterMemberComponents {
35
38
  storageRepo: IRepo;
36
39
  peerNetwork: IPeerNetwork;
37
40
  peerId: PeerId;
41
+ privateKey: PrivateKey;
38
42
  protocolPrefix?: string;
39
43
  partitionDetector?: PartitionDetector;
40
44
  fretService?: FretService;
41
45
  validator?: ITransactionValidator;
46
+ reputation?: IPeerReputation;
47
+ consensusConfig?: ClusterConsensusConfig;
42
48
  }
43
49
 
44
50
  export function clusterMember(components: ClusterMemberComponents): ClusterMember {
@@ -46,10 +52,13 @@ export function clusterMember(components: ClusterMemberComponents): ClusterMembe
46
52
  components.storageRepo,
47
53
  components.peerNetwork,
48
54
  components.peerId,
55
+ components.privateKey,
49
56
  components.protocolPrefix,
50
57
  components.partitionDetector,
51
58
  components.fretService,
52
- components.validator
59
+ components.validator,
60
+ components.reputation,
61
+ components.consensusConfig
53
62
  );
54
63
  }
55
64
 
@@ -69,20 +78,29 @@ export class ClusterMember implements ICluster {
69
78
  private cleanupQueue: string[] = [];
70
79
  // Serialize concurrent updates for the same transaction
71
80
  private pendingUpdates: Map<string, Promise<ClusterRecord>> = new Map();
81
+ // Temporarily set during validateSignatures so verifySignature can access the record
82
+ private currentValidationRecord?: ClusterRecord;
83
+
84
+ /** Effective super-majority threshold. Defaults to 1.0 (unanimity) for backward compatibility. */
85
+ private readonly superMajorityThreshold: number;
72
86
 
73
87
  constructor(
74
88
  private readonly storageRepo: IRepo,
75
89
  private readonly peerNetwork: IPeerNetwork,
76
90
  private readonly peerId: PeerId,
91
+ private readonly privateKey: PrivateKey,
77
92
  private readonly protocolPrefix?: string,
78
93
  private readonly partitionDetector?: PartitionDetector,
79
94
  private readonly fretService?: FretService,
80
- private readonly validator?: ITransactionValidator
95
+ private readonly validator?: ITransactionValidator,
96
+ private readonly reputation?: IPeerReputation,
97
+ consensusConfig?: ClusterConsensusConfig
81
98
  ) {
82
- // Periodically clean up expired transactions
83
- setInterval(() => this.queueExpiredTransactions(), 60000);
99
+ this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? 1.0;
100
+ // Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
101
+ setInterval(() => this.queueExpiredTransactions(), 60000).unref();
84
102
  // Process cleanup queue
85
- setInterval(() => this.processCleanupQueue(), 1000);
103
+ setInterval(() => this.processCleanupQueue(), 1000).unref();
86
104
  }
87
105
 
88
106
  /**
@@ -118,7 +136,7 @@ export class ClusterMember implements ICluster {
118
136
  // Remove from pending updates after a short delay to allow concurrent calls to see it
119
137
  setTimeout(() => {
120
138
  this.pendingUpdates.delete(record.messageHash);
121
- }, 100);
139
+ }, 100).unref();
122
140
  }
123
141
  }
124
142
 
@@ -288,7 +306,8 @@ export class ClusterMember implements ICluster {
288
306
  }
289
307
 
290
308
  /**
291
- * Merges two records, validating that non-signature fields match
309
+ * Merges two records, validating that non-signature fields match.
310
+ * Detects equivocation (same peer changing vote type) and applies penalties.
292
311
  */
293
312
  private async mergeRecords(existing: ClusterRecord, incoming: ClusterRecord): Promise<ClusterRecord> {
294
313
  log('cluster-member:merge-records', {
@@ -309,14 +328,64 @@ export class ClusterMember implements ICluster {
309
328
  throw new Error('Peers mismatch');
310
329
  }
311
330
 
312
- // Merge signatures, keeping the most recent valid ones
331
+ // Merge signatures with equivocation detection
332
+ const mergedPromises = this.detectEquivocation(
333
+ existing.promises, incoming.promises, 'promise', existing.messageHash
334
+ );
335
+ const mergedCommits = this.detectEquivocation(
336
+ existing.commits, incoming.commits, 'commit', existing.messageHash
337
+ );
338
+
313
339
  return {
314
340
  ...existing,
315
- promises: { ...existing.promises, ...incoming.promises },
316
- commits: { ...existing.commits, ...incoming.commits }
341
+ promises: mergedPromises,
342
+ commits: mergedCommits
317
343
  };
318
344
  }
319
345
 
346
+ /**
347
+ * Compares existing vs incoming signatures for the same peers.
348
+ * If a peer's vote type changed (approve↔reject), that's equivocation:
349
+ * report a penalty and keep the first-seen signature.
350
+ * New peers are accepted normally.
351
+ */
352
+ private detectEquivocation(
353
+ existing: Record<string, Signature>,
354
+ incoming: Record<string, Signature>,
355
+ phase: 'promise' | 'commit',
356
+ messageHash: string
357
+ ): Record<string, Signature> {
358
+ const merged = { ...existing };
359
+
360
+ for (const [peerId, incomingSig] of Object.entries(incoming)) {
361
+ const existingSig = existing[peerId];
362
+ if (existingSig) {
363
+ if (existingSig.type !== incomingSig.type) {
364
+ // Equivocation detected: peer changed their vote type
365
+ log('cluster-member:equivocation-detected', {
366
+ peerId,
367
+ phase,
368
+ messageHash,
369
+ existingType: existingSig.type,
370
+ incomingType: incomingSig.type
371
+ });
372
+ this.reputation?.reportPeer(
373
+ peerId,
374
+ PenaltyReason.Equivocation,
375
+ `${phase}:${messageHash}:${existingSig.type}->${incomingSig.type}`
376
+ );
377
+ // Keep first-seen signature — do not let the peer flip their vote
378
+ }
379
+ // Same type: keep existing (no-op, already in merged)
380
+ } else {
381
+ // New peer — accept normally
382
+ merged[peerId] = incomingSig;
383
+ }
384
+ }
385
+
386
+ return merged;
387
+ }
388
+
320
389
  private async validateRecord(record: ClusterRecord): Promise<void> {
321
390
  // Validate message hash matches the message content
322
391
  const expectedHash = await this.computeMessageHash(record.message);
@@ -344,20 +413,27 @@ export class ClusterMember implements ICluster {
344
413
  }
345
414
 
346
415
  private async validateSignatures(record: ClusterRecord): Promise<void> {
347
- // Validate promise signatures
348
- const promiseHash = await this.computePromiseHash(record);
349
- for (const [peerId, signature] of Object.entries(record.promises)) {
350
- if (!await this.verifySignature(peerId, promiseHash, signature)) {
351
- throw new Error(`Invalid promise signature from ${peerId}`);
416
+ this.currentValidationRecord = record;
417
+ try {
418
+ // Validate promise signatures
419
+ const promiseHash = await this.computePromiseHash(record);
420
+ for (const [peerId, signature] of Object.entries(record.promises)) {
421
+ if (!await this.verifySignature(peerId, promiseHash, signature)) {
422
+ this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `promise:${record.messageHash}`);
423
+ throw new Error(`Invalid promise signature from ${peerId}`);
424
+ }
352
425
  }
353
- }
354
426
 
355
- // Validate commit signatures
356
- const commitHash = await this.computeCommitHash(record);
357
- for (const [peerId, signature] of Object.entries(record.commits)) {
358
- if (!await this.verifySignature(peerId, commitHash, signature)) {
359
- throw new Error(`Invalid commit signature from ${peerId}`);
427
+ // Validate commit signatures
428
+ const commitHash = await this.computeCommitHash(record);
429
+ for (const [peerId, signature] of Object.entries(record.commits)) {
430
+ if (!await this.verifySignature(peerId, commitHash, signature)) {
431
+ this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `commit:${record.messageHash}`);
432
+ throw new Error(`Invalid commit signature from ${peerId}`);
433
+ }
360
434
  }
435
+ } finally {
436
+ this.currentValidationRecord = undefined;
361
437
  }
362
438
  }
363
439
 
@@ -373,21 +449,40 @@ export class ClusterMember implements ICluster {
373
449
  return uint8ArrayToString(hashBytes.digest, 'base64url');
374
450
  }
375
451
 
452
+ private computeSigningPayload(hash: string, type: string, rejectReason?: string): Uint8Array {
453
+ const payload = hash + ':' + type + (rejectReason ? ':' + rejectReason : '');
454
+ return new TextEncoder().encode(payload);
455
+ }
456
+
457
+ private async signVote(hash: string, type: 'approve' | 'reject', rejectReason?: string): Promise<string> {
458
+ const payload = this.computeSigningPayload(hash, type, rejectReason);
459
+ const sigBytes = await this.privateKey.sign(payload);
460
+ return uint8ArrayToString(sigBytes, 'base64url');
461
+ }
462
+
376
463
  private async verifySignature(peerId: string, hash: string, signature: Signature): Promise<boolean> {
377
- // TODO: Implement actual signature verification
378
- return true;
464
+ const peerInfo = this.currentValidationRecord?.peers[peerId];
465
+ if (!peerInfo?.publicKey?.length) {
466
+ throw new Error(`No public key for peer ${peerId}`);
467
+ }
468
+ const pubKey = publicKeyFromRaw(peerInfo.publicKey);
469
+ const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
470
+ const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
471
+ return pubKey.verify(payload, sigBytes);
379
472
  }
380
473
 
381
474
  private async getTransactionPhase(record: ClusterRecord): Promise<TransactionPhase> {
382
475
  const peerCount = Object.keys(record.peers).length;
383
476
  const promiseCount = Object.keys(record.promises).length;
384
- const commitCount = Object.keys(record.commits).length;
385
477
  const ourId = this.peerId.toString();
386
478
 
387
- // Check for rejections
479
+ const superMajority = Math.ceil(peerCount * this.superMajorityThreshold);
480
+ const maxAllowedRejections = peerCount - superMajority;
481
+
482
+ // Check for rejections — rejected if too many rejections to ever reach super-majority
388
483
  const rejectedPromises = Object.values(record.promises).filter(s => s.type === 'reject');
389
484
  const rejectedCommits = Object.values(record.commits).filter(s => s.type === 'reject');
390
- if (rejectedPromises.length > 0 || this.hasMajority(rejectedCommits.length, peerCount)) {
485
+ if (rejectedPromises.length > maxAllowedRejections || this.hasMajority(rejectedCommits.length, peerCount)) {
391
486
  return TransactionPhase.Rejected;
392
487
  }
393
488
 
@@ -396,14 +491,15 @@ export class ClusterMember implements ICluster {
396
491
  return TransactionPhase.OurPromiseNeeded;
397
492
  }
398
493
 
399
- // Check if still collecting promises
400
- if (promiseCount < peerCount) {
401
- return TransactionPhase.Promising;
494
+ // Check if we have enough approved promises to proceed to commit
495
+ const approvedPromises = Object.values(record.promises).filter(s => s.type === 'approve');
496
+ if (approvedPromises.length >= superMajority && !record.commits[ourId]) {
497
+ return TransactionPhase.OurCommitNeeded;
402
498
  }
403
499
 
404
- // Check if we need to commit
405
- if (promiseCount === peerCount && !record.commits[ourId]) {
406
- return TransactionPhase.OurCommitNeeded;
500
+ // Check if still collecting promises
501
+ if (promiseCount < peerCount && approvedPromises.length < superMajority) {
502
+ return TransactionPhase.Promising;
407
503
  }
408
504
 
409
505
  // Check for consensus
@@ -423,9 +519,14 @@ export class ClusterMember implements ICluster {
423
519
  // Validate pend operations if we have a validator
424
520
  const validationResult = await this.validatePendOperations(record);
425
521
 
522
+ const promiseHash = await this.computePromiseHash(record);
523
+ const type = validationResult.valid ? 'approve' as const : 'reject' as const;
524
+ const rejectReason = validationResult.valid ? undefined : validationResult.reason;
525
+ const sig = await this.signVote(promiseHash, type, rejectReason);
526
+
426
527
  const signature: Signature = validationResult.valid
427
- ? { type: 'approve', signature: 'approved' }
428
- : { type: 'reject', signature: 'rejected', rejectReason: validationResult.reason };
528
+ ? { type: 'approve', signature: sig }
529
+ : { type: 'reject', signature: sig, rejectReason };
429
530
 
430
531
  if (!validationResult.valid) {
431
532
  log('cluster-member:validation-rejected', {
@@ -491,9 +592,11 @@ export class ClusterMember implements ICluster {
491
592
  if (this.hasLocalCommit(record)) {
492
593
  return record;
493
594
  }
595
+ const commitHash = await this.computeCommitHash(record);
596
+ const sig = await this.signVote(commitHash, 'approve');
494
597
  const signature: Signature = {
495
598
  type: 'approve',
496
- signature: 'committed' // TODO: Actually sign the commit hash
599
+ signature: sig
497
600
  };
498
601
 
499
602
  return {
@@ -584,11 +687,11 @@ export class ClusterMember implements ICluster {
584
687
  promiseTimeout: setTimeout(
585
688
  () => this.handleExpiration(record.messageHash),
586
689
  record.message.expiration - Date.now()
587
- ),
690
+ ).unref(),
588
691
  resolutionTimeout: setTimeout(
589
692
  () => this.resolveWithPeers(record.messageHash),
590
693
  record.message.expiration + 5000 - Date.now()
591
- )
694
+ ).unref()
592
695
  };
593
696
  }
594
697
 
@@ -749,10 +852,13 @@ export class ClusterMember implements ICluster {
749
852
  if (!state) return;
750
853
 
751
854
  if (!state.record.promises[this.peerId.toString()]) {
855
+ const rejectReason = 'Transaction expired';
856
+ const promiseHash = await this.computePromiseHash(state.record);
857
+ const sig = await this.signVote(promiseHash, 'reject', rejectReason);
752
858
  const signature: Signature = {
753
859
  type: 'reject',
754
- signature: 'rejected',
755
- rejectReason: 'Transaction expired'
860
+ signature: sig,
861
+ rejectReason
756
862
  };
757
863
 
758
864
  const updatedRecord = {
@@ -0,0 +1,223 @@
1
+ import type { Startable, Libp2p, PeerId } from '@libp2p/interface'
2
+ import { hashKey } from 'p2p-fret'
3
+ import type { FretService } from 'p2p-fret'
4
+ import type { PartitionDetector } from './partition-detector.js'
5
+ import type { ArachnodeFretAdapter, ArachnodeInfo } from '../storage/arachnode-fret-adapter.js'
6
+ import { createLogger } from '../logger.js'
7
+
8
+ const log = createLogger('rebalance-monitor')
9
+ const textEncoder = new TextEncoder()
10
+
11
+ export interface RebalanceEvent {
12
+ /** Block IDs this node has gained responsibility for */
13
+ gained: string[]
14
+ /** Block IDs this node has lost responsibility for */
15
+ lost: string[]
16
+ /** Peers that are now closer for the lost blocks: blockId → peerId[] */
17
+ newOwners: Map<string, string[]>
18
+ /** Timestamp of the topology change that triggered this */
19
+ triggeredAt: number
20
+ }
21
+
22
+ export interface RebalanceMonitorConfig {
23
+ /** Debounce window for topology changes (ms). Default: 5000 */
24
+ debounceMs?: number
25
+ /** Maximum frequency of full rebalance scans (ms). Default: 60000 */
26
+ minRebalanceIntervalMs?: number
27
+ /** Whether to suppress rebalancing during detected partitions. Default: true */
28
+ suppressDuringPartition?: boolean
29
+ }
30
+
31
+ export interface RebalanceMonitorDeps {
32
+ libp2p: Libp2p
33
+ fret: FretService
34
+ partitionDetector: PartitionDetector
35
+ fretAdapter: ArachnodeFretAdapter
36
+ }
37
+
38
+ type RebalanceHandler = (event: RebalanceEvent) => void
39
+
40
+ export class RebalanceMonitor implements Startable {
41
+ private running = false
42
+ private readonly trackedBlocks = new Set<string>()
43
+ private readonly responsibilitySnapshot = new Map<string, boolean>()
44
+ private readonly handlers: RebalanceHandler[] = []
45
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null
46
+ private lastRebalanceAt = 0
47
+ private pendingTopologyChange = false
48
+ private topologyChangeTimestamp = 0
49
+
50
+ private readonly debounceMs: number
51
+ private readonly minRebalanceIntervalMs: number
52
+ private readonly suppressDuringPartition: boolean
53
+
54
+ private readonly onConnectionOpen: () => void
55
+ private readonly onConnectionClose: () => void
56
+
57
+ constructor(
58
+ private readonly deps: RebalanceMonitorDeps,
59
+ config: RebalanceMonitorConfig = {}
60
+ ) {
61
+ this.debounceMs = config.debounceMs ?? 5000
62
+ this.minRebalanceIntervalMs = config.minRebalanceIntervalMs ?? 60000
63
+ this.suppressDuringPartition = config.suppressDuringPartition ?? true
64
+
65
+ this.onConnectionOpen = () => this.handleTopologyChange()
66
+ this.onConnectionClose = () => this.handleTopologyChange()
67
+ }
68
+
69
+ async start(): Promise<void> {
70
+ if (this.running) return
71
+ this.running = true
72
+
73
+ this.deps.libp2p.addEventListener('connection:open', this.onConnectionOpen)
74
+ this.deps.libp2p.addEventListener('connection:close', this.onConnectionClose)
75
+
76
+ log('started, tracking %d blocks', this.trackedBlocks.size)
77
+ }
78
+
79
+ async stop(): Promise<void> {
80
+ if (!this.running) return
81
+ this.running = false
82
+
83
+ this.deps.libp2p.removeEventListener('connection:open', this.onConnectionOpen)
84
+ this.deps.libp2p.removeEventListener('connection:close', this.onConnectionClose)
85
+
86
+ if (this.debounceTimer) {
87
+ clearTimeout(this.debounceTimer)
88
+ this.debounceTimer = null
89
+ }
90
+
91
+ this.pendingTopologyChange = false
92
+ log('stopped')
93
+ }
94
+
95
+ onRebalance(handler: RebalanceHandler): void {
96
+ this.handlers.push(handler)
97
+ }
98
+
99
+ trackBlock(blockId: string): void {
100
+ this.trackedBlocks.add(blockId)
101
+ }
102
+
103
+ untrackBlock(blockId: string): void {
104
+ this.trackedBlocks.delete(blockId)
105
+ this.responsibilitySnapshot.delete(blockId)
106
+ }
107
+
108
+ getTrackedBlockCount(): number {
109
+ return this.trackedBlocks.size
110
+ }
111
+
112
+ async checkNow(): Promise<RebalanceEvent | null> {
113
+ return this.performRebalanceCheck(Date.now())
114
+ }
115
+
116
+ private handleTopologyChange(): void {
117
+ if (!this.running) return
118
+
119
+ if (!this.pendingTopologyChange) {
120
+ this.topologyChangeTimestamp = Date.now()
121
+ }
122
+ this.pendingTopologyChange = true
123
+
124
+ if (this.debounceTimer) {
125
+ clearTimeout(this.debounceTimer)
126
+ }
127
+
128
+ this.debounceTimer = setTimeout(() => {
129
+ this.debounceTimer = null
130
+ this.pendingTopologyChange = false
131
+ this.maybeRebalance()
132
+ }, this.debounceMs)
133
+ }
134
+
135
+ private async maybeRebalance(): Promise<void> {
136
+ if (!this.running) return
137
+
138
+ const now = Date.now()
139
+ const elapsed = now - this.lastRebalanceAt
140
+ if (elapsed < this.minRebalanceIntervalMs) {
141
+ log('throttled, %dms since last rebalance', elapsed)
142
+ return
143
+ }
144
+
145
+ const event = await this.performRebalanceCheck(this.topologyChangeTimestamp || now)
146
+ if (event) {
147
+ this.emitEvent(event)
148
+ }
149
+ }
150
+
151
+ private async performRebalanceCheck(triggeredAt: number): Promise<RebalanceEvent | null> {
152
+ if (this.suppressDuringPartition && this.deps.partitionDetector.detectPartition()) {
153
+ log('partition detected, suppressing rebalance')
154
+ return null
155
+ }
156
+
157
+ if (this.trackedBlocks.size === 0) {
158
+ this.lastRebalanceAt = Date.now()
159
+ return null
160
+ }
161
+
162
+ const selfId = this.deps.libp2p.peerId.toString()
163
+ const gained: string[] = []
164
+ const lost: string[] = []
165
+ const newOwners = new Map<string, string[]>()
166
+
167
+ for (const blockId of this.trackedBlocks) {
168
+ const key = textEncoder.encode(blockId)
169
+ const coord = await hashKey(key)
170
+
171
+ // Get the current cohort — assembleCohort returns peer IDs sorted by distance
172
+ const cohort = this.deps.fret.assembleCohort(coord, this.getCohortSize())
173
+ const isResponsible = cohort.includes(selfId)
174
+ const wasResponsible = this.responsibilitySnapshot.get(blockId) ?? false
175
+
176
+ if (isResponsible && !wasResponsible) {
177
+ gained.push(blockId)
178
+ } else if (!isResponsible && wasResponsible) {
179
+ lost.push(blockId)
180
+ // The cohort members are the new owners
181
+ newOwners.set(blockId, cohort.filter(id => id !== selfId))
182
+ }
183
+
184
+ this.responsibilitySnapshot.set(blockId, isResponsible)
185
+ }
186
+
187
+ this.lastRebalanceAt = Date.now()
188
+
189
+ if (gained.length === 0 && lost.length === 0) {
190
+ return null
191
+ }
192
+
193
+ log('rebalance check: gained=%d lost=%d', gained.length, lost.length)
194
+
195
+ return { gained, lost, newOwners, triggeredAt }
196
+ }
197
+
198
+ private getCohortSize(): number {
199
+ const diag: any = (this.deps.fret as any).getDiagnostics?.()
200
+ const estimate = diag?.estimate ?? diag?.n
201
+ if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
202
+ return Math.max(1, Math.min(3, Math.ceil(Math.sqrt(estimate))))
203
+ }
204
+ return 3
205
+ }
206
+
207
+ private emitEvent(event: RebalanceEvent): void {
208
+ for (const handler of this.handlers) {
209
+ try {
210
+ handler(event)
211
+ } catch (err) {
212
+ log('handler error: %O', err)
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Update ArachnodeInfo status through the fret adapter.
219
+ */
220
+ setStatus(status: ArachnodeInfo['status']): void {
221
+ this.deps.fretAdapter.setStatus(status)
222
+ }
223
+ }
@@ -0,0 +1,28 @@
1
+ import type { PeerId } from '@libp2p/interface';
2
+ import { sortPeersByDistance, type KnownPeer } from '../routing/responsibility.js';
3
+
4
+ /**
5
+ * Select arbitrators for a dispute using XOR-distance from the block ID.
6
+ * Selects the next K peers beyond the original cluster (positions K+1 through 2K).
7
+ * This ensures independence (arbitrators are not in the original cluster)
8
+ * and determinism (all parties agree on who arbitrates).
9
+ */
10
+ export function selectArbitrators(
11
+ allPeers: KnownPeer[],
12
+ blockIdBytes: Uint8Array,
13
+ excludePeerIds: Set<string>,
14
+ count: number,
15
+ ): PeerId[] {
16
+ // Sort all peers by XOR distance to the block ID
17
+ const sorted = sortPeersByDistance(allPeers, blockIdBytes);
18
+
19
+ // Skip peers in the original cluster (and self), select the next K
20
+ const arbitrators: PeerId[] = [];
21
+ for (const peer of sorted) {
22
+ if (arbitrators.length >= count) break;
23
+ if (excludePeerIds.has(peer.id.toString())) continue;
24
+ arbitrators.push(peer.id);
25
+ }
26
+
27
+ return arbitrators;
28
+ }
@@ -0,0 +1,41 @@
1
+ import type { PeerId, IPeerNetwork } from '@optimystic/db-core';
2
+ import { ProtocolClient } from '../protocol-client.js';
3
+ import type { DisputeChallenge, DisputeResolution, ArbitrationVote, DisputeMessage } from './types.js';
4
+
5
+ /**
6
+ * Client for the dispute protocol. Sends challenges to arbitrators
7
+ * and broadcasts resolutions.
8
+ */
9
+ export class DisputeClient extends ProtocolClient {
10
+ private readonly protocol: string;
11
+
12
+ constructor(peerId: PeerId, peerNetwork: IPeerNetwork, protocolPrefix?: string) {
13
+ super(peerId, peerNetwork);
14
+ this.protocol = (protocolPrefix ?? '/db-p2p') + '/dispute/1.0.0';
15
+ }
16
+
17
+ static create(peerId: PeerId, peerNetwork: IPeerNetwork, protocolPrefix?: string): DisputeClient {
18
+ return new DisputeClient(peerId, peerNetwork, protocolPrefix);
19
+ }
20
+
21
+ /** Send a challenge to an arbitrator and get their vote */
22
+ async sendChallenge(challenge: DisputeChallenge, timeoutMs?: number): Promise<ArbitrationVote> {
23
+ const message: DisputeMessage = { type: 'challenge', challenge };
24
+ const signal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined;
25
+ const response = await this.processMessage<{ type: 'vote'; vote: ArbitrationVote }>(
26
+ message,
27
+ this.protocol,
28
+ { signal }
29
+ );
30
+ return response.vote;
31
+ }
32
+
33
+ /** Send a resolution to a peer (broadcast) */
34
+ async sendResolution(resolution: DisputeResolution): Promise<void> {
35
+ const message: DisputeMessage = { type: 'resolution', resolution };
36
+ await this.processMessage<{ type: 'ack' }>(
37
+ message,
38
+ this.protocol,
39
+ );
40
+ }
41
+ }