@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
@@ -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
 
@@ -344,20 +362,27 @@ export class ClusterMember implements ICluster {
344
362
  }
345
363
 
346
364
  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}`);
365
+ this.currentValidationRecord = record;
366
+ try {
367
+ // Validate promise signatures
368
+ const promiseHash = await this.computePromiseHash(record);
369
+ for (const [peerId, signature] of Object.entries(record.promises)) {
370
+ if (!await this.verifySignature(peerId, promiseHash, signature)) {
371
+ this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `promise:${record.messageHash}`);
372
+ throw new Error(`Invalid promise signature from ${peerId}`);
373
+ }
352
374
  }
353
- }
354
375
 
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}`);
376
+ // Validate commit signatures
377
+ const commitHash = await this.computeCommitHash(record);
378
+ for (const [peerId, signature] of Object.entries(record.commits)) {
379
+ if (!await this.verifySignature(peerId, commitHash, signature)) {
380
+ this.reputation?.reportPeer(peerId, PenaltyReason.InvalidSignature, `commit:${record.messageHash}`);
381
+ throw new Error(`Invalid commit signature from ${peerId}`);
382
+ }
360
383
  }
384
+ } finally {
385
+ this.currentValidationRecord = undefined;
361
386
  }
362
387
  }
363
388
 
@@ -373,21 +398,40 @@ export class ClusterMember implements ICluster {
373
398
  return uint8ArrayToString(hashBytes.digest, 'base64url');
374
399
  }
375
400
 
401
+ private computeSigningPayload(hash: string, type: string, rejectReason?: string): Uint8Array {
402
+ const payload = hash + ':' + type + (rejectReason ? ':' + rejectReason : '');
403
+ return new TextEncoder().encode(payload);
404
+ }
405
+
406
+ private async signVote(hash: string, type: 'approve' | 'reject', rejectReason?: string): Promise<string> {
407
+ const payload = this.computeSigningPayload(hash, type, rejectReason);
408
+ const sigBytes = await this.privateKey.sign(payload);
409
+ return uint8ArrayToString(sigBytes, 'base64url');
410
+ }
411
+
376
412
  private async verifySignature(peerId: string, hash: string, signature: Signature): Promise<boolean> {
377
- // TODO: Implement actual signature verification
378
- return true;
413
+ const peerInfo = this.currentValidationRecord?.peers[peerId];
414
+ if (!peerInfo?.publicKey?.length) {
415
+ throw new Error(`No public key for peer ${peerId}`);
416
+ }
417
+ const pubKey = publicKeyFromRaw(peerInfo.publicKey);
418
+ const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
419
+ const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
420
+ return pubKey.verify(payload, sigBytes);
379
421
  }
380
422
 
381
423
  private async getTransactionPhase(record: ClusterRecord): Promise<TransactionPhase> {
382
424
  const peerCount = Object.keys(record.peers).length;
383
425
  const promiseCount = Object.keys(record.promises).length;
384
- const commitCount = Object.keys(record.commits).length;
385
426
  const ourId = this.peerId.toString();
386
427
 
387
- // Check for rejections
428
+ const superMajority = Math.ceil(peerCount * this.superMajorityThreshold);
429
+ const maxAllowedRejections = peerCount - superMajority;
430
+
431
+ // Check for rejections — rejected if too many rejections to ever reach super-majority
388
432
  const rejectedPromises = Object.values(record.promises).filter(s => s.type === 'reject');
389
433
  const rejectedCommits = Object.values(record.commits).filter(s => s.type === 'reject');
390
- if (rejectedPromises.length > 0 || this.hasMajority(rejectedCommits.length, peerCount)) {
434
+ if (rejectedPromises.length > maxAllowedRejections || this.hasMajority(rejectedCommits.length, peerCount)) {
391
435
  return TransactionPhase.Rejected;
392
436
  }
393
437
 
@@ -396,14 +440,15 @@ export class ClusterMember implements ICluster {
396
440
  return TransactionPhase.OurPromiseNeeded;
397
441
  }
398
442
 
399
- // Check if still collecting promises
400
- if (promiseCount < peerCount) {
401
- return TransactionPhase.Promising;
443
+ // Check if we have enough approved promises to proceed to commit
444
+ const approvedPromises = Object.values(record.promises).filter(s => s.type === 'approve');
445
+ if (approvedPromises.length >= superMajority && !record.commits[ourId]) {
446
+ return TransactionPhase.OurCommitNeeded;
402
447
  }
403
448
 
404
- // Check if we need to commit
405
- if (promiseCount === peerCount && !record.commits[ourId]) {
406
- return TransactionPhase.OurCommitNeeded;
449
+ // Check if still collecting promises
450
+ if (promiseCount < peerCount && approvedPromises.length < superMajority) {
451
+ return TransactionPhase.Promising;
407
452
  }
408
453
 
409
454
  // Check for consensus
@@ -423,9 +468,14 @@ export class ClusterMember implements ICluster {
423
468
  // Validate pend operations if we have a validator
424
469
  const validationResult = await this.validatePendOperations(record);
425
470
 
471
+ const promiseHash = await this.computePromiseHash(record);
472
+ const type = validationResult.valid ? 'approve' as const : 'reject' as const;
473
+ const rejectReason = validationResult.valid ? undefined : validationResult.reason;
474
+ const sig = await this.signVote(promiseHash, type, rejectReason);
475
+
426
476
  const signature: Signature = validationResult.valid
427
- ? { type: 'approve', signature: 'approved' }
428
- : { type: 'reject', signature: 'rejected', rejectReason: validationResult.reason };
477
+ ? { type: 'approve', signature: sig }
478
+ : { type: 'reject', signature: sig, rejectReason };
429
479
 
430
480
  if (!validationResult.valid) {
431
481
  log('cluster-member:validation-rejected', {
@@ -491,9 +541,11 @@ export class ClusterMember implements ICluster {
491
541
  if (this.hasLocalCommit(record)) {
492
542
  return record;
493
543
  }
544
+ const commitHash = await this.computeCommitHash(record);
545
+ const sig = await this.signVote(commitHash, 'approve');
494
546
  const signature: Signature = {
495
547
  type: 'approve',
496
- signature: 'committed' // TODO: Actually sign the commit hash
548
+ signature: sig
497
549
  };
498
550
 
499
551
  return {
@@ -584,11 +636,11 @@ export class ClusterMember implements ICluster {
584
636
  promiseTimeout: setTimeout(
585
637
  () => this.handleExpiration(record.messageHash),
586
638
  record.message.expiration - Date.now()
587
- ),
639
+ ).unref(),
588
640
  resolutionTimeout: setTimeout(
589
641
  () => this.resolveWithPeers(record.messageHash),
590
642
  record.message.expiration + 5000 - Date.now()
591
- )
643
+ ).unref()
592
644
  };
593
645
  }
594
646
 
@@ -749,10 +801,13 @@ export class ClusterMember implements ICluster {
749
801
  if (!state) return;
750
802
 
751
803
  if (!state.record.promises[this.peerId.toString()]) {
804
+ const rejectReason = 'Transaction expired';
805
+ const promiseHash = await this.computePromiseHash(state.record);
806
+ const sig = await this.signVote(promiseHash, 'reject', rejectReason);
752
807
  const signature: Signature = {
753
808
  type: 'reject',
754
- signature: 'rejected',
755
- rejectReason: 'Transaction expired'
809
+ signature: sig,
810
+ rejectReason
756
811
  };
757
812
 
758
813
  const updatedRecord = {
@@ -0,0 +1,225 @@
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
+
10
+ export interface RebalanceEvent {
11
+ /** Block IDs this node has gained responsibility for */
12
+ gained: string[]
13
+ /** Block IDs this node has lost responsibility for */
14
+ lost: string[]
15
+ /** Peers that are now closer for the lost blocks: blockId → peerId[] */
16
+ newOwners: Map<string, string[]>
17
+ /** Timestamp of the topology change that triggered this */
18
+ triggeredAt: number
19
+ }
20
+
21
+ export interface RebalanceMonitorConfig {
22
+ /** Debounce window for topology changes (ms). Default: 5000 */
23
+ debounceMs?: number
24
+ /** Maximum frequency of full rebalance scans (ms). Default: 60000 */
25
+ minRebalanceIntervalMs?: number
26
+ /** Whether to suppress rebalancing during detected partitions. Default: true */
27
+ suppressDuringPartition?: boolean
28
+ }
29
+
30
+ export interface RebalanceMonitorDeps {
31
+ libp2p: Libp2p
32
+ fret: FretService
33
+ partitionDetector: PartitionDetector
34
+ fretAdapter: ArachnodeFretAdapter
35
+ }
36
+
37
+ type RebalanceHandler = (event: RebalanceEvent) => void
38
+
39
+ export class RebalanceMonitor implements Startable {
40
+ private running = false
41
+ private readonly trackedBlocks = new Set<string>()
42
+ private readonly responsibilitySnapshot = new Map<string, boolean>()
43
+ private readonly handlers: RebalanceHandler[] = []
44
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null
45
+ private lastRebalanceAt = 0
46
+ private pendingTopologyChange = false
47
+ private topologyChangeTimestamp = 0
48
+
49
+ private readonly debounceMs: number
50
+ private readonly minRebalanceIntervalMs: number
51
+ private readonly suppressDuringPartition: boolean
52
+
53
+ private readonly onConnectionOpen: () => void
54
+ private readonly onConnectionClose: () => void
55
+
56
+ constructor(
57
+ private readonly deps: RebalanceMonitorDeps,
58
+ config: RebalanceMonitorConfig = {}
59
+ ) {
60
+ this.debounceMs = config.debounceMs ?? 5000
61
+ this.minRebalanceIntervalMs = config.minRebalanceIntervalMs ?? 60000
62
+ this.suppressDuringPartition = config.suppressDuringPartition ?? true
63
+
64
+ this.onConnectionOpen = () => this.handleTopologyChange()
65
+ this.onConnectionClose = () => this.handleTopologyChange()
66
+ }
67
+
68
+ async start(): Promise<void> {
69
+ if (this.running) return
70
+ this.running = true
71
+
72
+ this.deps.libp2p.addEventListener('connection:open', this.onConnectionOpen)
73
+ this.deps.libp2p.addEventListener('connection:close', this.onConnectionClose)
74
+
75
+ log('started, tracking %d blocks', this.trackedBlocks.size)
76
+ }
77
+
78
+ async stop(): Promise<void> {
79
+ if (!this.running) return
80
+ this.running = false
81
+
82
+ this.deps.libp2p.removeEventListener('connection:open', this.onConnectionOpen)
83
+ this.deps.libp2p.removeEventListener('connection:close', this.onConnectionClose)
84
+
85
+ if (this.debounceTimer) {
86
+ clearTimeout(this.debounceTimer)
87
+ this.debounceTimer = null
88
+ }
89
+
90
+ this.pendingTopologyChange = false
91
+ log('stopped')
92
+ }
93
+
94
+ onRebalance(handler: RebalanceHandler): void {
95
+ this.handlers.push(handler)
96
+ }
97
+
98
+ trackBlock(blockId: string): void {
99
+ this.trackedBlocks.add(blockId)
100
+ }
101
+
102
+ untrackBlock(blockId: string): void {
103
+ this.trackedBlocks.delete(blockId)
104
+ this.responsibilitySnapshot.delete(blockId)
105
+ }
106
+
107
+ getTrackedBlockCount(): number {
108
+ return this.trackedBlocks.size
109
+ }
110
+
111
+ async checkNow(): Promise<RebalanceEvent | null> {
112
+ return this.performRebalanceCheck(Date.now())
113
+ }
114
+
115
+ private handleTopologyChange(): void {
116
+ if (!this.running) return
117
+
118
+ if (!this.pendingTopologyChange) {
119
+ this.topologyChangeTimestamp = Date.now()
120
+ }
121
+ this.pendingTopologyChange = true
122
+
123
+ if (this.debounceTimer) {
124
+ clearTimeout(this.debounceTimer)
125
+ }
126
+
127
+ this.debounceTimer = setTimeout(() => {
128
+ this.debounceTimer = null
129
+ this.pendingTopologyChange = false
130
+ this.maybeRebalance()
131
+ }, this.debounceMs)
132
+ }
133
+
134
+ private async maybeRebalance(): Promise<void> {
135
+ if (!this.running) return
136
+
137
+ const now = Date.now()
138
+ const elapsed = now - this.lastRebalanceAt
139
+ if (elapsed < this.minRebalanceIntervalMs) {
140
+ log('throttled, %dms since last rebalance', elapsed)
141
+ return
142
+ }
143
+
144
+ const event = await this.performRebalanceCheck(this.topologyChangeTimestamp || now)
145
+ if (event) {
146
+ this.emitEvent(event)
147
+ }
148
+ }
149
+
150
+ private async performRebalanceCheck(triggeredAt: number): Promise<RebalanceEvent | null> {
151
+ if (this.suppressDuringPartition && this.deps.partitionDetector.detectPartition()) {
152
+ log('partition detected, suppressing rebalance')
153
+ return null
154
+ }
155
+
156
+ if (this.trackedBlocks.size === 0) {
157
+ this.lastRebalanceAt = Date.now()
158
+ return null
159
+ }
160
+
161
+ const selfId = this.deps.libp2p.peerId.toString()
162
+ const gained: string[] = []
163
+ const lost: string[] = []
164
+ const newOwners = new Map<string, string[]>()
165
+
166
+ for (const blockId of this.trackedBlocks) {
167
+ const key = new TextEncoder().encode(blockId)
168
+ const coord = await hashKey(key)
169
+
170
+ // Get the current cohort — assembleCohort returns peer IDs sorted by distance
171
+ const cohort = this.deps.fret.assembleCohort(coord, this.getCohortSize())
172
+ const isResponsible = cohort.includes(selfId)
173
+ const wasResponsible = this.responsibilitySnapshot.get(blockId) ?? false
174
+
175
+ if (isResponsible && !wasResponsible) {
176
+ gained.push(blockId)
177
+ } else if (!isResponsible && wasResponsible) {
178
+ lost.push(blockId)
179
+ // The cohort members are the new owners
180
+ newOwners.set(blockId, cohort.filter(id => id !== selfId))
181
+ }
182
+
183
+ this.responsibilitySnapshot.set(blockId, isResponsible)
184
+ }
185
+
186
+ this.lastRebalanceAt = Date.now()
187
+
188
+ if (gained.length === 0 && lost.length === 0) {
189
+ return null
190
+ }
191
+
192
+ log('rebalance check: gained=%d lost=%d', gained.length, lost.length)
193
+
194
+ return { gained, lost, newOwners, triggeredAt }
195
+ }
196
+
197
+ private getCohortSize(): number {
198
+ const diag: any = (this.deps.fret as any).getDiagnostics?.()
199
+ const estimate = diag?.estimate ?? diag?.n
200
+ if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
201
+ return Math.max(1, Math.min(3, Math.ceil(Math.sqrt(estimate))))
202
+ }
203
+ return 3
204
+ }
205
+
206
+ private emitEvent(event: RebalanceEvent): void {
207
+ for (const handler of this.handlers) {
208
+ try {
209
+ handler(event)
210
+ } catch (err) {
211
+ log('handler error: %O', err)
212
+ }
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Update ArachnodeInfo status through the fret adapter.
218
+ */
219
+ setStatus(status: ArachnodeInfo['status']): void {
220
+ const current = this.deps.fretAdapter.getMyArachnodeInfo()
221
+ if (current) {
222
+ this.deps.fretAdapter.setArachnodeInfo({ ...current, status })
223
+ }
224
+ }
225
+ }
@@ -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
+ }