@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
@@ -0,0 +1,453 @@
1
+ import type { ClusterRecord, ITransactionValidator } from '@optimystic/db-core';
2
+ import type { PeerId, PrivateKey } from '@libp2p/interface';
3
+ import { sha256 } from 'multiformats/hashes/sha2';
4
+ import { base58btc } from 'multiformats/bases/base58';
5
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
6
+ import { publicKeyFromRaw } from '@libp2p/crypto/keys';
7
+ import type {
8
+ ValidationEvidence,
9
+ DisputeChallenge,
10
+ ArbitrationVote,
11
+ DisputeResolution,
12
+ DisputeConfig,
13
+ DisputeMessage,
14
+ DisputeStatus,
15
+ } from './types.js';
16
+ import { DEFAULT_DISPUTE_CONFIG } from './types.js';
17
+ import { EngineHealthMonitor } from './engine-health-monitor.js';
18
+ import type { IPeerReputation } from '../reputation/types.js';
19
+ import { PenaltyReason } from '../reputation/types.js';
20
+ import { createLogger } from '../logger.js';
21
+ import type { IPeerNetwork } from '@optimystic/db-core';
22
+ import type { DisputeClient } from './client.js';
23
+
24
+ const log = createLogger('dispute');
25
+
26
+ /** Callback to create a DisputeClient for a given peer */
27
+ export type CreateDisputeClient = (peerId: PeerId) => DisputeClient;
28
+
29
+ /** Callback to re-execute a transaction and produce validation evidence */
30
+ export type RevalidateTransaction = (record: ClusterRecord) => Promise<ValidationEvidence | undefined>;
31
+
32
+ export interface DisputeServiceInit {
33
+ peerId: PeerId;
34
+ privateKey: PrivateKey;
35
+ peerNetwork: IPeerNetwork;
36
+ createDisputeClient: CreateDisputeClient;
37
+ reputation?: IPeerReputation;
38
+ validator?: ITransactionValidator;
39
+ revalidate?: RevalidateTransaction;
40
+ config?: Partial<DisputeConfig>;
41
+ /** Select arbitrators for a dispute (next K peers beyond the original cluster) */
42
+ selectArbitrators: (blockId: string, excludePeers: string[], count: number) => Promise<PeerId[]>;
43
+ }
44
+
45
+ /**
46
+ * Manages the dispute escalation protocol.
47
+ *
48
+ * When a transaction proceeds despite minority rejections, the overridden
49
+ * minority can escalate to independent arbitrators. The service coordinates
50
+ * challenge initiation, arbitration vote collection, and resolution.
51
+ */
52
+ export class DisputeService {
53
+ private readonly peerId: PeerId;
54
+ private readonly privateKey: PrivateKey;
55
+ private readonly createDisputeClient: CreateDisputeClient;
56
+ private readonly reputation?: IPeerReputation;
57
+ private readonly revalidate?: RevalidateTransaction;
58
+ private readonly config: DisputeConfig;
59
+ private readonly engineHealth: EngineHealthMonitor;
60
+ private readonly selectArbitrators: DisputeServiceInit['selectArbitrators'];
61
+
62
+ /** Active disputes initiated by this node */
63
+ private activeDisputes: Map<string, DisputeChallenge> = new Map();
64
+ /** Resolved disputes (disputeId -> resolution) */
65
+ private resolvedDisputes: Map<string, DisputeResolution> = new Map();
66
+ /** Track which transactions we've already disputed (prevent spam) */
67
+ private disputedTransactions: Set<string> = new Set();
68
+
69
+ constructor(init: DisputeServiceInit) {
70
+ this.peerId = init.peerId;
71
+ this.privateKey = init.privateKey;
72
+ this.createDisputeClient = init.createDisputeClient;
73
+ this.reputation = init.reputation;
74
+ this.revalidate = init.revalidate;
75
+ this.config = { ...DEFAULT_DISPUTE_CONFIG, ...init.config };
76
+ this.engineHealth = new EngineHealthMonitor(this.config);
77
+ this.selectArbitrators = init.selectArbitrators;
78
+ }
79
+
80
+ /** Get the engine health monitor */
81
+ getEngineHealth(): EngineHealthMonitor {
82
+ return this.engineHealth;
83
+ }
84
+
85
+ /** Check if disputes are enabled */
86
+ isEnabled(): boolean {
87
+ return this.config.disputeEnabled;
88
+ }
89
+
90
+ /** Get the dispute status for a transaction, if any */
91
+ getDisputeStatus(messageHash: string): DisputeStatus | undefined {
92
+ // Check if there's an active dispute for this transaction
93
+ for (const [, challenge] of this.activeDisputes) {
94
+ if (challenge.originalMessageHash === messageHash) {
95
+ return 'committed-disputed';
96
+ }
97
+ }
98
+ // Check resolved disputes
99
+ for (const [, resolution] of this.resolvedDisputes) {
100
+ const challenge = this.findChallengeForDispute(resolution.disputeId);
101
+ if (challenge && challenge.originalMessageHash === messageHash) {
102
+ if (resolution.outcome === 'challenger-wins') return 'committed-invalidated';
103
+ if (resolution.outcome === 'majority-wins') return 'committed-validated';
104
+ return 'committed-disputed'; // inconclusive
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * Initiate a dispute when this node's rejection was overridden.
112
+ * Called by ClusterMember when it detects a disputed commit.
113
+ */
114
+ async initiateDispute(record: ClusterRecord, evidence: ValidationEvidence): Promise<DisputeResolution | undefined> {
115
+ if (!this.config.disputeEnabled) {
116
+ log('dispute-disabled', { messageHash: record.messageHash });
117
+ return undefined;
118
+ }
119
+
120
+ // One dispute per transaction
121
+ if (this.disputedTransactions.has(record.messageHash)) {
122
+ log('dispute-already-initiated', { messageHash: record.messageHash });
123
+ return undefined;
124
+ }
125
+
126
+ // Don't dispute if our engine is unhealthy
127
+ if (this.engineHealth.isUnhealthy()) {
128
+ log('dispute-skipped-unhealthy', { messageHash: record.messageHash });
129
+ return undefined;
130
+ }
131
+
132
+ this.disputedTransactions.add(record.messageHash);
133
+
134
+ const timestamp = Date.now();
135
+ const disputeId = await this.computeDisputeId(record.messageHash, this.peerId.toString(), timestamp);
136
+ const signature = await this.signDispute(disputeId);
137
+
138
+ const defaultTtl = record.message.expiration
139
+ ? (record.message.expiration - Date.now()) * 2
140
+ : this.config.disputeArbitrationTimeoutMs * 2;
141
+ const expiration = timestamp + Math.max(defaultTtl, this.config.disputeArbitrationTimeoutMs);
142
+
143
+ const challenge: DisputeChallenge = {
144
+ disputeId,
145
+ originalMessageHash: record.messageHash,
146
+ originalRecord: record,
147
+ challengerPeerId: this.peerId.toString(),
148
+ challengerEvidence: evidence,
149
+ signature,
150
+ timestamp,
151
+ expiration,
152
+ };
153
+
154
+ this.activeDisputes.set(disputeId, challenge);
155
+ log('dispute-initiated', { disputeId, messageHash: record.messageHash });
156
+
157
+ // Select arbitrators and collect votes
158
+ const blockIds = record.coordinatingBlockIds ?? [];
159
+ const blockId = blockIds[0] ?? record.messageHash;
160
+ const originalPeers = Object.keys(record.peers);
161
+ const arbitratorCount = this.config.arbitratorCount ?? originalPeers.length;
162
+
163
+ let arbitrators: PeerId[];
164
+ try {
165
+ arbitrators = await this.selectArbitrators(blockId, originalPeers, arbitratorCount);
166
+ } catch (err) {
167
+ log('dispute-arbitrator-selection-failed', { disputeId, error: err instanceof Error ? err.message : String(err) });
168
+ this.activeDisputes.delete(disputeId);
169
+ return undefined;
170
+ }
171
+
172
+ if (arbitrators.length === 0) {
173
+ log('dispute-no-arbitrators', { disputeId });
174
+ this.activeDisputes.delete(disputeId);
175
+ return undefined;
176
+ }
177
+
178
+ // Send challenge to all arbitrators and collect votes
179
+ const votes = await this.collectVotes(challenge, arbitrators);
180
+ const resolution = this.resolveDispute(challenge, votes);
181
+
182
+ this.activeDisputes.delete(disputeId);
183
+ this.resolvedDisputes.set(disputeId, resolution);
184
+
185
+ // Apply reputation effects
186
+ this.applyReputationEffects(resolution, record);
187
+
188
+ // Broadcast resolution
189
+ await this.broadcastResolution(resolution, arbitrators, originalPeers);
190
+
191
+ log('dispute-resolved', {
192
+ disputeId,
193
+ outcome: resolution.outcome,
194
+ votes: votes.length,
195
+ affectedPeers: resolution.affectedPeers.length,
196
+ });
197
+
198
+ return resolution;
199
+ }
200
+
201
+ /**
202
+ * Handle an incoming dispute challenge (when this node is selected as arbitrator).
203
+ * Re-executes the transaction and returns a vote.
204
+ */
205
+ async handleChallenge(challenge: DisputeChallenge): Promise<ArbitrationVote> {
206
+ log('dispute-handle-challenge', { disputeId: challenge.disputeId });
207
+
208
+ // Verify the challenge signature
209
+ const validSignature = await this.verifyDisputeSignature(
210
+ challenge.disputeId,
211
+ challenge.signature,
212
+ challenge.originalRecord.peers[challenge.challengerPeerId]?.publicKey
213
+ );
214
+
215
+ if (!validSignature) {
216
+ log('dispute-invalid-challenge-signature', { disputeId: challenge.disputeId });
217
+ return this.makeVote(challenge.disputeId, 'inconclusive', {
218
+ computedHash: '',
219
+ engineId: 'unknown',
220
+ schemaHash: '',
221
+ blockStateHashes: {},
222
+ });
223
+ }
224
+
225
+ // Re-execute the transaction to produce our own evidence
226
+ let evidence: ValidationEvidence | undefined;
227
+ if (this.revalidate) {
228
+ try {
229
+ evidence = await this.revalidate(challenge.originalRecord);
230
+ } catch (err) {
231
+ log('dispute-revalidation-failed', {
232
+ disputeId: challenge.disputeId,
233
+ error: err instanceof Error ? err.message : String(err),
234
+ });
235
+ }
236
+ }
237
+
238
+ if (!evidence) {
239
+ // Can't re-execute — vote inconclusive
240
+ return this.makeVote(challenge.disputeId, 'inconclusive', {
241
+ computedHash: '',
242
+ engineId: 'unknown',
243
+ schemaHash: '',
244
+ blockStateHashes: {},
245
+ });
246
+ }
247
+
248
+ // Compare our evidence with the challenger's
249
+ let vote: ArbitrationVote['vote'];
250
+ if (evidence.computedHash === challenge.challengerEvidence.computedHash) {
251
+ // Our re-execution matches the challenger — the challenger is right
252
+ vote = 'agree-with-challenger';
253
+ } else {
254
+ // Our re-execution differs from the challenger — the majority is likely right
255
+ vote = 'agree-with-majority';
256
+ }
257
+
258
+ return this.makeVote(challenge.disputeId, vote, evidence);
259
+ }
260
+
261
+ /**
262
+ * Handle an incoming dispute resolution (broadcast from the dispute initiator).
263
+ */
264
+ handleResolution(resolution: DisputeResolution): void {
265
+ this.resolvedDisputes.set(resolution.disputeId, resolution);
266
+ log('dispute-resolution-received', {
267
+ disputeId: resolution.disputeId,
268
+ outcome: resolution.outcome,
269
+ });
270
+
271
+ // If we were penalized and the challenger won, check engine health
272
+ const ourId = this.peerId.toString();
273
+ const ourPenalty = resolution.affectedPeers.find(p => p.peerId === ourId);
274
+ if (ourPenalty && ourPenalty.reason === 'false-approval') {
275
+ this.engineHealth.recordDisputeLoss();
276
+ }
277
+ }
278
+
279
+ /** Collect votes from arbitrators with a timeout */
280
+ private async collectVotes(challenge: DisputeChallenge, arbitrators: PeerId[]): Promise<ArbitrationVote[]> {
281
+ const timeoutMs = this.config.disputeArbitrationTimeoutMs;
282
+ const votes: ArbitrationVote[] = [];
283
+
284
+ const votePromises = arbitrators.map(async (arbitratorPeerId) => {
285
+ try {
286
+ const client = this.createDisputeClient(arbitratorPeerId);
287
+ const vote = await client.sendChallenge(challenge, timeoutMs);
288
+ return vote;
289
+ } catch (err) {
290
+ log('dispute-vote-collection-failed', {
291
+ disputeId: challenge.disputeId,
292
+ arbitrator: arbitratorPeerId.toString(),
293
+ error: err instanceof Error ? err.message : String(err),
294
+ });
295
+ return undefined;
296
+ }
297
+ });
298
+
299
+ const results = await Promise.allSettled(votePromises);
300
+ for (const result of results) {
301
+ if (result.status === 'fulfilled' && result.value) {
302
+ votes.push(result.value);
303
+ }
304
+ }
305
+
306
+ return votes;
307
+ }
308
+
309
+ /** Determine dispute resolution from collected votes */
310
+ resolveDispute(challenge: DisputeChallenge, votes: ArbitrationVote[]): DisputeResolution {
311
+ const challengerVotes = votes.filter(v => v.vote === 'agree-with-challenger').length;
312
+ const majorityVotes = votes.filter(v => v.vote === 'agree-with-majority').length;
313
+ const totalDecisive = challengerVotes + majorityVotes;
314
+
315
+ // Need super-majority of decisive votes (>2/3)
316
+ const superMajorityThreshold = Math.ceil(totalDecisive * 2 / 3);
317
+
318
+ let outcome: DisputeResolution['outcome'];
319
+ const affectedPeers: DisputeResolution['affectedPeers'] = [];
320
+
321
+ if (totalDecisive === 0) {
322
+ outcome = 'inconclusive';
323
+ } else if (challengerVotes >= superMajorityThreshold) {
324
+ outcome = 'challenger-wins';
325
+ // Penalize majority peers who approved the transaction
326
+ const originalRecord = challenge.originalRecord;
327
+ for (const [peerId, signature] of Object.entries(originalRecord.promises)) {
328
+ if (signature.type === 'approve' && peerId !== challenge.challengerPeerId) {
329
+ affectedPeers.push({ peerId, reason: 'false-approval' });
330
+ }
331
+ }
332
+ } else if (majorityVotes >= superMajorityThreshold) {
333
+ outcome = 'majority-wins';
334
+ // Penalize the challenger
335
+ affectedPeers.push({ peerId: challenge.challengerPeerId, reason: 'dispute-lost' });
336
+ } else {
337
+ outcome = 'inconclusive';
338
+ }
339
+
340
+ return {
341
+ disputeId: challenge.disputeId,
342
+ outcome,
343
+ votes,
344
+ affectedPeers,
345
+ timestamp: Date.now(),
346
+ };
347
+ }
348
+
349
+ /** Apply reputation effects based on dispute resolution */
350
+ private applyReputationEffects(resolution: DisputeResolution, record: ClusterRecord): void {
351
+ if (!this.reputation) return;
352
+
353
+ for (const affected of resolution.affectedPeers) {
354
+ if (affected.reason === 'false-approval') {
355
+ // Weight: 40 as specified in ticket
356
+ this.reputation.reportPeer(affected.peerId, PenaltyReason.FalseApproval,
357
+ `dispute:false-approval:${resolution.disputeId}`);
358
+ } else if (affected.reason === 'dispute-lost') {
359
+ // Weight: 30 as specified in ticket
360
+ this.reputation.reportPeer(affected.peerId, PenaltyReason.DisputeLost,
361
+ `dispute:dispute-lost:${resolution.disputeId}`);
362
+ }
363
+ }
364
+
365
+ // If challenger wins, track engine health for majority peers
366
+ if (resolution.outcome === 'challenger-wins') {
367
+ const ourId = this.peerId.toString();
368
+ if (resolution.affectedPeers.some(p => p.peerId === ourId)) {
369
+ this.engineHealth.recordDisputeLoss();
370
+ }
371
+ }
372
+ }
373
+
374
+ /** Broadcast resolution to all interested parties */
375
+ private async broadcastResolution(
376
+ resolution: DisputeResolution,
377
+ arbitrators: PeerId[],
378
+ originalPeers: string[]
379
+ ): Promise<void> {
380
+ const allTargets = new Set<string>();
381
+ for (const arb of arbitrators) allTargets.add(arb.toString());
382
+ for (const peer of originalPeers) allTargets.add(peer);
383
+ // Don't send to self
384
+ allTargets.delete(this.peerId.toString());
385
+
386
+ const promises = Array.from(allTargets).map(async (peerIdStr) => {
387
+ try {
388
+ const { peerIdFromString } = await import('@libp2p/peer-id');
389
+ const client = this.createDisputeClient(peerIdFromString(peerIdStr));
390
+ await client.sendResolution(resolution);
391
+ } catch (err) {
392
+ log('dispute-broadcast-failed', {
393
+ disputeId: resolution.disputeId,
394
+ peer: peerIdStr,
395
+ error: err instanceof Error ? err.message : String(err),
396
+ });
397
+ }
398
+ });
399
+
400
+ await Promise.allSettled(promises);
401
+ }
402
+
403
+ private async makeVote(
404
+ disputeId: string,
405
+ vote: ArbitrationVote['vote'],
406
+ evidence: ValidationEvidence
407
+ ): Promise<ArbitrationVote> {
408
+ const payload = `${disputeId}:${vote}:${evidence.computedHash}`;
409
+ const payloadBytes = new TextEncoder().encode(payload);
410
+ const sigBytes = await this.privateKey.sign(payloadBytes);
411
+
412
+ return {
413
+ disputeId,
414
+ arbitratorPeerId: this.peerId.toString(),
415
+ vote,
416
+ evidence,
417
+ signature: uint8ArrayToString(sigBytes, 'base64url'),
418
+ };
419
+ }
420
+
421
+ private async computeDisputeId(messageHash: string, peerId: string, timestamp: number): Promise<string> {
422
+ const input = `${messageHash}+${peerId}+${timestamp}`;
423
+ const inputBytes = new TextEncoder().encode(input);
424
+ const hashBytes = await sha256.digest(inputBytes);
425
+ return base58btc.encode(hashBytes.digest);
426
+ }
427
+
428
+ private async signDispute(disputeId: string): Promise<string> {
429
+ const payload = new TextEncoder().encode(disputeId);
430
+ const sigBytes = await this.privateKey.sign(payload);
431
+ return uint8ArrayToString(sigBytes, 'base64url');
432
+ }
433
+
434
+ private async verifyDisputeSignature(
435
+ disputeId: string,
436
+ signature: string,
437
+ publicKey?: Uint8Array
438
+ ): Promise<boolean> {
439
+ if (!publicKey?.length) return false;
440
+ try {
441
+ const pubKey = publicKeyFromRaw(publicKey);
442
+ const payload = new TextEncoder().encode(disputeId);
443
+ const sigBytes = uint8ArrayFromString(signature, 'base64url');
444
+ return pubKey.verify(payload, sigBytes);
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+
450
+ private findChallengeForDispute(disputeId: string): DisputeChallenge | undefined {
451
+ return this.activeDisputes.get(disputeId);
452
+ }
453
+ }
@@ -0,0 +1,86 @@
1
+ import type { EngineHealthState, DisputeConfig } from './types.js';
2
+ import { DEFAULT_DISPUTE_CONFIG } from './types.js';
3
+ import { createLogger } from '../logger.js';
4
+
5
+ const log = createLogger('engine-health');
6
+
7
+ /**
8
+ * Tracks local engine health based on dispute outcomes.
9
+ * When the node repeatedly loses disputes (its validations are wrong),
10
+ * it flags itself as unhealthy and stops participating in promise voting.
11
+ */
12
+ export class EngineHealthMonitor {
13
+ private state: EngineHealthState = {
14
+ disputesLost: 0,
15
+ recentLosses: [],
16
+ unhealthy: false,
17
+ };
18
+
19
+ private readonly threshold: number;
20
+ private readonly windowMs: number;
21
+
22
+ constructor(config?: Partial<DisputeConfig>) {
23
+ this.threshold = config?.engineHealthDisputeThreshold ?? DEFAULT_DISPUTE_CONFIG.engineHealthDisputeThreshold;
24
+ this.windowMs = config?.engineHealthWindowMs ?? DEFAULT_DISPUTE_CONFIG.engineHealthWindowMs;
25
+ }
26
+
27
+ /** Record a dispute loss (our validation was wrong) */
28
+ recordDisputeLoss(): void {
29
+ const now = Date.now();
30
+ this.state.recentLosses.push(now);
31
+ this.pruneOldLosses(now);
32
+ this.state.disputesLost = this.state.recentLosses.length;
33
+
34
+ log('dispute-loss-recorded', {
35
+ recentLosses: this.state.recentLosses.length,
36
+ threshold: this.threshold,
37
+ wasUnhealthy: this.state.unhealthy,
38
+ });
39
+
40
+ if (this.state.recentLosses.length >= this.threshold && !this.state.unhealthy) {
41
+ this.state.unhealthy = true;
42
+ this.state.unhealthySince = now;
43
+ log('engine-marked-unhealthy', {
44
+ disputesLost: this.state.recentLosses.length,
45
+ threshold: this.threshold,
46
+ windowMs: this.windowMs,
47
+ });
48
+ }
49
+ }
50
+
51
+ /** Check if the engine is currently unhealthy */
52
+ isUnhealthy(): boolean {
53
+ this.pruneOldLosses(Date.now());
54
+ // Auto-recover if losses drop below threshold
55
+ if (this.state.unhealthy && this.state.recentLosses.length < this.threshold) {
56
+ this.state.unhealthy = false;
57
+ this.state.unhealthySince = undefined;
58
+ log('engine-auto-recovered', {
59
+ recentLosses: this.state.recentLosses.length,
60
+ threshold: this.threshold,
61
+ });
62
+ }
63
+ return this.state.unhealthy;
64
+ }
65
+
66
+ /** Get the current health state */
67
+ getState(): Readonly<EngineHealthState> {
68
+ this.pruneOldLosses(Date.now());
69
+ return { ...this.state };
70
+ }
71
+
72
+ /** Reset health state (for testing or admin recovery) */
73
+ reset(): void {
74
+ this.state = {
75
+ disputesLost: 0,
76
+ recentLosses: [],
77
+ unhealthy: false,
78
+ };
79
+ }
80
+
81
+ private pruneOldLosses(now: number): void {
82
+ const cutoff = now - this.windowMs;
83
+ this.state.recentLosses = this.state.recentLosses.filter(t => t > cutoff);
84
+ this.state.disputesLost = this.state.recentLosses.length;
85
+ }
86
+ }
@@ -0,0 +1,17 @@
1
+ export { DisputeService, type CreateDisputeClient, type RevalidateTransaction, type DisputeServiceInit } from './dispute-service.js';
2
+ export { DisputeClient } from './client.js';
3
+ export { DisputeProtocolService, disputeProtocolService, type DisputeProtocolServiceComponents, type DisputeProtocolServiceInit } from './service.js';
4
+ export { EngineHealthMonitor } from './engine-health-monitor.js';
5
+ export {
6
+ type ValidationEvidence,
7
+ type DisputeChallenge,
8
+ type ArbitrationVote,
9
+ type DisputeResolution,
10
+ type DisputeMessage,
11
+ type DisputeStatus,
12
+ type DisputeConfig,
13
+ type DisputePenaltyReason,
14
+ type EngineHealthState,
15
+ DEFAULT_DISPUTE_CONFIG,
16
+ } from './types.js';
17
+ export { selectArbitrators } from './arbitrator-selection.js';
@@ -0,0 +1,119 @@
1
+ import { pipe } from 'it-pipe';
2
+ import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
+ import type { Startable, Logger, Stream, Connection, StreamHandler } from '@libp2p/interface';
4
+ import type { Uint8ArrayList } from 'uint8arraylist';
5
+ import type { DisputeMessage, ArbitrationVote, DisputeResolution } from './types.js';
6
+ import type { DisputeService } from './dispute-service.js';
7
+
8
+ interface BaseComponents {
9
+ logger: { forComponent: (name: string) => Logger };
10
+ registrar: {
11
+ handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>;
12
+ unhandle: (protocol: string) => Promise<void>;
13
+ };
14
+ }
15
+
16
+ export interface DisputeProtocolServiceComponents extends BaseComponents {
17
+ disputeService: DisputeService;
18
+ }
19
+
20
+ export interface DisputeProtocolServiceInit {
21
+ protocol?: string;
22
+ protocolPrefix?: string;
23
+ maxInboundStreams?: number;
24
+ maxOutboundStreams?: number;
25
+ }
26
+
27
+ export function disputeProtocolService(init: DisputeProtocolServiceInit = {}): (components: DisputeProtocolServiceComponents) => DisputeProtocolService {
28
+ return (components: DisputeProtocolServiceComponents) => new DisputeProtocolService(components, init);
29
+ }
30
+
31
+ /**
32
+ * Libp2p service that handles dispute protocol messages.
33
+ * Follows the same pattern as ClusterService.
34
+ */
35
+ export class DisputeProtocolService implements Startable {
36
+ private readonly protocol: string;
37
+ private readonly maxInboundStreams: number;
38
+ private readonly maxOutboundStreams: number;
39
+ private readonly log: Logger;
40
+ private readonly disputeService: DisputeService;
41
+ private readonly components: DisputeProtocolServiceComponents;
42
+ private running: boolean;
43
+
44
+ constructor(components: DisputeProtocolServiceComponents, init: DisputeProtocolServiceInit = {}) {
45
+ this.components = components;
46
+ this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/dispute/1.0.0';
47
+ this.maxInboundStreams = init.maxInboundStreams ?? 16;
48
+ this.maxOutboundStreams = init.maxOutboundStreams ?? 32;
49
+ this.log = components.logger.forComponent('db-p2p:dispute');
50
+ this.disputeService = components.disputeService;
51
+ this.running = false;
52
+ }
53
+
54
+ readonly [Symbol.toStringTag] = '@libp2p/dispute';
55
+
56
+ async start(): Promise<void> {
57
+ if (this.running) return;
58
+
59
+ await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
60
+ maxInboundStreams: this.maxInboundStreams,
61
+ maxOutboundStreams: this.maxOutboundStreams,
62
+ });
63
+
64
+ this.running = true;
65
+ }
66
+
67
+ async stop(): Promise<void> {
68
+ if (!this.running) return;
69
+ await this.components.registrar.unhandle(this.protocol);
70
+ this.running = false;
71
+ }
72
+
73
+ private handleIncomingStream(stream: Stream, connection: Connection): void {
74
+ const peerId = connection.remotePeer;
75
+
76
+ const processStream = async function* (this: DisputeProtocolService, source: AsyncIterable<Uint8ArrayList>) {
77
+ for await (const msg of source) {
78
+ const decoded = new TextDecoder().decode(msg.subarray());
79
+ const message = JSON.parse(decoded) as DisputeMessage;
80
+
81
+ let response: any;
82
+ switch (message.type) {
83
+ case 'challenge': {
84
+ const vote = await this.disputeService.handleChallenge(message.challenge);
85
+ response = { type: 'vote', vote };
86
+ break;
87
+ }
88
+ case 'resolution': {
89
+ this.disputeService.handleResolution(message.resolution);
90
+ response = { type: 'ack' };
91
+ break;
92
+ }
93
+ default:
94
+ throw new Error(`Unknown dispute message type: ${(message as any).type}`);
95
+ }
96
+
97
+ yield new TextEncoder().encode(JSON.stringify(response));
98
+ }
99
+ };
100
+
101
+ void (async () => {
102
+ try {
103
+ const responses = pipe(
104
+ stream,
105
+ (source) => lpDecode(source),
106
+ processStream.bind(this),
107
+ (source) => lpEncode(source)
108
+ );
109
+ for await (const chunk of responses) {
110
+ stream.send(chunk);
111
+ }
112
+ await stream.close();
113
+ } catch (err) {
114
+ this.log.error('error handling dispute protocol message from %p - %e', peerId, err);
115
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
116
+ }
117
+ })();
118
+ }
119
+ }