@optimystic/db-p2p 0.3.0 → 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 (37) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  2. package/dist/src/cluster/block-transfer-service.js +4 -1
  3. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  4. package/dist/src/cluster/block-transfer.d.ts +2 -16
  5. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  6. package/dist/src/cluster/block-transfer.js +68 -71
  7. package/dist/src/cluster/block-transfer.js.map +1 -1
  8. package/dist/src/cluster/cluster-repo.d.ts +9 -1
  9. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  10. package/dist/src/cluster/cluster-repo.js +39 -4
  11. package/dist/src/cluster/cluster-repo.js.map +1 -1
  12. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  13. package/dist/src/cluster/rebalance-monitor.js +3 -5
  14. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  15. package/dist/src/dispute/dispute-service.d.ts +2 -0
  16. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  17. package/dist/src/dispute/dispute-service.js +4 -1
  18. package/dist/src/dispute/dispute-service.js.map +1 -1
  19. package/dist/src/index.d.ts +2 -0
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/index.js +2 -0
  22. package/dist/src/index.js.map +1 -1
  23. package/dist/src/libp2p-node-base.js +1 -1
  24. package/dist/src/libp2p-node-base.js.map +1 -1
  25. package/dist/src/repo/coordinator-repo.d.ts +2 -2
  26. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  27. package/dist/src/repo/coordinator-repo.js +5 -5
  28. package/dist/src/repo/coordinator-repo.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/cluster/block-transfer-service.ts +4 -1
  31. package/src/cluster/block-transfer.ts +80 -99
  32. package/src/cluster/cluster-repo.ts +55 -4
  33. package/src/cluster/rebalance-monitor.ts +3 -5
  34. package/src/dispute/dispute-service.ts +4 -1
  35. package/src/index.ts +2 -0
  36. package/src/libp2p-node-base.ts +1 -1
  37. package/src/repo/coordinator-repo.ts +7 -7
@@ -1,28 +1,13 @@
1
- import type { BlockId, IRepo } from '@optimystic/db-core';
2
- import type { PeerId, IPeerNetwork } from '@optimystic/db-core';
1
+ import type { IRepo, IPeerNetwork } from '@optimystic/db-core';
3
2
  import { peerIdFromString } from '@libp2p/peer-id';
4
3
  import type { PartitionDetector } from './partition-detector.js';
5
4
  import type { RestorationCoordinator } from '../storage/restoration-coordinator-v2.js';
6
- import { BlockTransferClient, type BlockTransferRequest, type BlockTransferResponse } from './block-transfer-service.js';
5
+ import { BlockTransferClient } from './block-transfer-service.js';
6
+ import type { RebalanceEvent } from './rebalance-monitor.js';
7
7
  import { createLogger } from '../logger.js';
8
8
 
9
9
  const log = createLogger('block-transfer');
10
10
 
11
- /**
12
- * Rebalance event describing gained/lost block responsibilities.
13
- * Matches the RebalanceMonitor spec from the sibling ticket.
14
- */
15
- export interface RebalanceEvent {
16
- /** Block IDs this node has gained responsibility for */
17
- gained: string[];
18
- /** Block IDs this node has lost responsibility for */
19
- lost: string[];
20
- /** Peers that are now closer for the lost blocks: blockId → peerId[] */
21
- newOwners: Map<string, string[]>;
22
- /** Timestamp of the topology change that triggered this */
23
- triggeredAt: number;
24
- }
25
-
26
11
  export interface BlockTransferConfig {
27
12
  /** Max concurrent transfers. Default: 4 */
28
13
  maxConcurrency?: number;
@@ -34,11 +19,6 @@ export interface BlockTransferConfig {
34
19
  enablePush?: boolean;
35
20
  }
36
21
 
37
- interface TransferTask {
38
- blockId: string;
39
- attempt: number;
40
- }
41
-
42
22
  /**
43
23
  * Coordinates block transfers in response to rebalance events.
44
24
  *
@@ -84,11 +64,9 @@ export class BlockTransferCoordinator {
84
64
  const succeeded: string[] = [];
85
65
  const failed: string[] = [];
86
66
 
87
- const tasks = blockIds
88
- .filter(id => !this.inFlight.has(`pull:${id}`))
89
- .map(id => ({ blockId: id, attempt: 0 }));
67
+ const ids = blockIds.filter(id => !this.inFlight.has(`pull:${id}`));
90
68
 
91
- await Promise.all(tasks.map(task => this.executePull(task, succeeded, failed)));
69
+ await Promise.all(ids.map(id => this.executePull(id, succeeded, failed)));
92
70
 
93
71
  return { succeeded, failed };
94
72
  }
@@ -111,11 +89,9 @@ export class BlockTransferCoordinator {
111
89
  const succeeded: string[] = [];
112
90
  const failed: string[] = [];
113
91
 
114
- const tasks = blockIds
115
- .filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id))
116
- .map(id => ({ blockId: id, attempt: 0 }));
92
+ const ids = blockIds.filter(id => !this.inFlight.has(`push:${id}`) && newOwners.has(id));
117
93
 
118
- await Promise.all(tasks.map(task => this.executePush(task, newOwners, succeeded, failed)));
94
+ await Promise.all(ids.map(id => this.executePush(id, newOwners, succeeded, failed)));
119
95
 
120
96
  return { succeeded, failed };
121
97
  }
@@ -139,37 +115,40 @@ export class BlockTransferCoordinator {
139
115
  }
140
116
 
141
117
  private async executePull(
142
- task: TransferTask,
118
+ blockId: string,
143
119
  succeeded: string[],
144
120
  failed: string[]
145
121
  ): Promise<void> {
146
- const key = `pull:${task.blockId}`;
122
+ const key = `pull:${blockId}`;
147
123
  if (this.inFlight.has(key)) return;
148
124
  this.inFlight.add(key);
149
125
 
150
126
  try {
151
- await this.acquireSemaphore();
152
- try {
153
- const archive = await this.withTimeout(
154
- this.restorationCoordinator.restore(task.blockId),
155
- this.transferTimeoutMs
156
- );
127
+ for (let attempt = 0; ; attempt++) {
128
+ await this.acquireSemaphore();
129
+ let archive: Awaited<ReturnType<RestorationCoordinator['restore']>>;
130
+ try {
131
+ archive = await this.withTimeout(
132
+ this.restorationCoordinator.restore(blockId),
133
+ this.transferTimeoutMs
134
+ );
135
+ } finally {
136
+ this.releaseSemaphore();
137
+ }
157
138
 
158
139
  if (archive) {
159
- log('pull:ok block=%s', task.blockId);
160
- succeeded.push(task.blockId);
161
- } else if (task.attempt < this.maxRetries) {
162
- log('pull:retry block=%s attempt=%d', task.blockId, task.attempt + 1);
163
- this.inFlight.delete(key);
164
- await this.delay(this.backoffMs(task.attempt));
165
- await this.executePull({ ...task, attempt: task.attempt + 1 }, succeeded, failed);
140
+ log('pull:ok block=%s', blockId);
141
+ succeeded.push(blockId);
166
142
  return;
167
- } else {
168
- log('pull:failed block=%s', task.blockId);
169
- failed.push(task.blockId);
170
143
  }
171
- } finally {
172
- this.releaseSemaphore();
144
+ if (attempt < this.maxRetries) {
145
+ log('pull:retry block=%s attempt=%d', blockId, attempt + 1);
146
+ await this.delay(this.backoffMs(attempt));
147
+ continue;
148
+ }
149
+ log('pull:failed block=%s', blockId);
150
+ failed.push(blockId);
151
+ return;
173
152
  }
174
153
  } finally {
175
154
  this.inFlight.delete(key);
@@ -177,71 +156,73 @@ export class BlockTransferCoordinator {
177
156
  }
178
157
 
179
158
  private async executePush(
180
- task: TransferTask,
159
+ blockId: string,
181
160
  newOwners: Map<string, string[]>,
182
161
  succeeded: string[],
183
162
  failed: string[]
184
163
  ): Promise<void> {
185
- const key = `push:${task.blockId}`;
164
+ const key = `push:${blockId}`;
186
165
  if (this.inFlight.has(key)) return;
187
166
  this.inFlight.add(key);
188
167
 
189
168
  try {
190
- await this.acquireSemaphore();
191
- try {
192
- const owners = newOwners.get(task.blockId);
193
- if (!owners || owners.length === 0) {
194
- failed.push(task.blockId);
195
- return;
196
- }
197
-
198
- // Read block data from local storage
199
- const result = await this.repo.get({ blockIds: [task.blockId] });
200
- const blockResult = result[task.blockId];
201
- if (!blockResult?.block) {
202
- log('push:no-local-data block=%s', task.blockId);
203
- failed.push(task.blockId);
204
- return;
205
- }
169
+ for (let attempt = 0; ; attempt++) {
170
+ await this.acquireSemaphore();
171
+ let pushed = false;
172
+ try {
173
+ const owners = newOwners.get(blockId);
174
+ if (!owners || owners.length === 0) {
175
+ failed.push(blockId);
176
+ return;
177
+ }
206
178
 
207
- const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
179
+ // Read block data from local storage
180
+ const result = await this.repo.get({ blockIds: [blockId] });
181
+ const blockResult = result[blockId];
182
+ if (!blockResult?.block) {
183
+ log('push:no-local-data block=%s', blockId);
184
+ failed.push(blockId);
185
+ return;
186
+ }
208
187
 
209
- // Push to at least one new owner
210
- let pushed = false;
211
- for (const ownerPeerIdStr of owners) {
212
- try {
213
- const peerId = peerIdFromString(ownerPeerIdStr);
214
- const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
215
- const response = await this.withTimeout(
216
- client.pushBlocks([task.blockId], [blockData]),
217
- this.transferTimeoutMs
218
- );
219
-
220
- if (response && !response.missing.includes(task.blockId)) {
221
- pushed = true;
222
- log('push:ok block=%s peer=%s', task.blockId, ownerPeerIdStr);
223
- break;
188
+ const blockData = new TextEncoder().encode(JSON.stringify(blockResult.block));
189
+
190
+ // Push to at least one new owner
191
+ for (const ownerPeerIdStr of owners) {
192
+ try {
193
+ const peerId = peerIdFromString(ownerPeerIdStr);
194
+ const client = new BlockTransferClient(peerId, this.peerNetwork, this.protocolPrefix);
195
+ const response = await this.withTimeout(
196
+ client.pushBlocks([blockId], [blockData]),
197
+ this.transferTimeoutMs
198
+ );
199
+
200
+ if (response && !response.missing.includes(blockId)) {
201
+ pushed = true;
202
+ log('push:ok block=%s peer=%s', blockId, ownerPeerIdStr);
203
+ break;
204
+ }
205
+ } catch (err) {
206
+ log('push:peer-error block=%s peer=%s err=%s',
207
+ blockId, ownerPeerIdStr, (err as Error).message);
224
208
  }
225
- } catch (err) {
226
- log('push:peer-error block=%s peer=%s err=%s',
227
- task.blockId, ownerPeerIdStr, (err as Error).message);
228
209
  }
210
+ } finally {
211
+ this.releaseSemaphore();
229
212
  }
230
213
 
231
214
  if (pushed) {
232
- succeeded.push(task.blockId);
233
- } else if (task.attempt < this.maxRetries) {
234
- log('push:retry block=%s attempt=%d', task.blockId, task.attempt + 1);
235
- this.inFlight.delete(key);
236
- await this.delay(this.backoffMs(task.attempt));
237
- await this.executePush({ ...task, attempt: task.attempt + 1 }, newOwners, succeeded, failed);
215
+ succeeded.push(blockId);
238
216
  return;
239
- } else {
240
- log('push:failed block=%s', task.blockId);
241
- failed.push(task.blockId);
242
217
  }
243
- } finally {
244
- this.releaseSemaphore();
218
+ if (attempt < this.maxRetries) {
219
+ log('push:retry block=%s attempt=%d', blockId, attempt + 1);
220
+ await this.delay(this.backoffMs(attempt));
221
+ continue;
222
+ }
223
+ log('push:failed block=%s', blockId);
224
+ failed.push(blockId);
225
+ return;
245
226
  }
246
227
  } finally {
247
228
  this.inFlight.delete(key);
@@ -306,7 +306,8 @@ export class ClusterMember implements ICluster {
306
306
  }
307
307
 
308
308
  /**
309
- * 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.
310
311
  */
311
312
  private async mergeRecords(existing: ClusterRecord, incoming: ClusterRecord): Promise<ClusterRecord> {
312
313
  log('cluster-member:merge-records', {
@@ -327,14 +328,64 @@ export class ClusterMember implements ICluster {
327
328
  throw new Error('Peers mismatch');
328
329
  }
329
330
 
330
- // 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
+
331
339
  return {
332
340
  ...existing,
333
- promises: { ...existing.promises, ...incoming.promises },
334
- commits: { ...existing.commits, ...incoming.commits }
341
+ promises: mergedPromises,
342
+ commits: mergedCommits
335
343
  };
336
344
  }
337
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
+
338
389
  private async validateRecord(record: ClusterRecord): Promise<void> {
339
390
  // Validate message hash matches the message content
340
391
  const expectedHash = await this.computeMessageHash(record.message);
@@ -6,6 +6,7 @@ import type { ArachnodeFretAdapter, ArachnodeInfo } from '../storage/arachnode-f
6
6
  import { createLogger } from '../logger.js'
7
7
 
8
8
  const log = createLogger('rebalance-monitor')
9
+ const textEncoder = new TextEncoder()
9
10
 
10
11
  export interface RebalanceEvent {
11
12
  /** Block IDs this node has gained responsibility for */
@@ -164,7 +165,7 @@ export class RebalanceMonitor implements Startable {
164
165
  const newOwners = new Map<string, string[]>()
165
166
 
166
167
  for (const blockId of this.trackedBlocks) {
167
- const key = new TextEncoder().encode(blockId)
168
+ const key = textEncoder.encode(blockId)
168
169
  const coord = await hashKey(key)
169
170
 
170
171
  // Get the current cohort — assembleCohort returns peer IDs sorted by distance
@@ -217,9 +218,6 @@ export class RebalanceMonitor implements Startable {
217
218
  * Update ArachnodeInfo status through the fret adapter.
218
219
  */
219
220
  setStatus(status: ArachnodeInfo['status']): void {
220
- const current = this.deps.fretAdapter.getMyArachnodeInfo()
221
- if (current) {
222
- this.deps.fretAdapter.setArachnodeInfo({ ...current, status })
223
- }
221
+ this.deps.fretAdapter.setStatus(status)
224
222
  }
225
223
  }
@@ -63,6 +63,8 @@ export class DisputeService {
63
63
  private activeDisputes: Map<string, DisputeChallenge> = new Map();
64
64
  /** Resolved disputes (disputeId -> resolution) */
65
65
  private resolvedDisputes: Map<string, DisputeResolution> = new Map();
66
+ /** Challenges retained after resolution for status lookups */
67
+ private resolvedChallenges: Map<string, DisputeChallenge> = new Map();
66
68
  /** Track which transactions we've already disputed (prevent spam) */
67
69
  private disputedTransactions: Set<string> = new Set();
68
70
 
@@ -179,6 +181,7 @@ export class DisputeService {
179
181
  const votes = await this.collectVotes(challenge, arbitrators);
180
182
  const resolution = this.resolveDispute(challenge, votes);
181
183
 
184
+ this.resolvedChallenges.set(disputeId, challenge);
182
185
  this.activeDisputes.delete(disputeId);
183
186
  this.resolvedDisputes.set(disputeId, resolution);
184
187
 
@@ -448,6 +451,6 @@ export class DisputeService {
448
451
  }
449
452
 
450
453
  private findChallengeForDispute(disputeId: string): DisputeChallenge | undefined {
451
- return this.activeDisputes.get(disputeId);
454
+ return this.activeDisputes.get(disputeId) ?? this.resolvedChallenges.get(disputeId);
452
455
  }
453
456
  }
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@ export * from "./cluster/client.js";
2
2
  export * from "./cluster/cluster-repo.js";
3
3
  export * from "./cluster/service.js";
4
4
  export * from "./cluster/rebalance-monitor.js";
5
+ export * from "./cluster/block-transfer.js";
6
+ export * from "./cluster/block-transfer-service.js";
5
7
  export * from "./protocol-client.js";
6
8
  export * from "./repo/client.js";
7
9
  export * from "./repo/cluster-coordinator.js";
@@ -315,7 +315,7 @@ export async function createLibp2pNodeBase(
315
315
  );
316
316
 
317
317
  // Create callback for querying cluster peers for their latest block revision
318
- const clusterLatestCallback: ClusterLatestCallback = async (peerId, blockId) => {
318
+ const clusterLatestCallback: ClusterLatestCallback = async (peerId, blockId, _context?) => {
319
319
  const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
320
320
  try {
321
321
  const response = await syncClient.requestBlock({ blockId, rev: undefined });
@@ -1,4 +1,4 @@
1
- import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev } from "@optimystic/db-core";
1
+ import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext } from "@optimystic/db-core";
2
2
  import { LruMap } from "@optimystic/db-core";
3
3
  import { ClusterCoordinator } from "./cluster-coordinator.js";
4
4
  import type { ClusterClient } from "../cluster/client.js";
@@ -22,7 +22,7 @@ interface LocalClusterWithExecutionTracking extends ICluster {
22
22
  * Callback to query a cluster peer for their latest revision of a block.
23
23
  * Returns the peer's latest ActionRev if they have the block, undefined otherwise.
24
24
  */
25
- export type ClusterLatestCallback = (peerId: PeerId, blockId: BlockId) => Promise<ActionRev | undefined>;
25
+ export type ClusterLatestCallback = (peerId: PeerId, blockId: BlockId, context?: ActionContext) => Promise<ActionRev | undefined>;
26
26
 
27
27
  interface CoordinatorRepoComponents {
28
28
  storageRepo: IRepo;
@@ -159,7 +159,7 @@ export class CoordinatorRepo implements IRepo {
159
159
  // If block not found locally (no state), try cluster peers
160
160
  if (!localEntry?.state?.latest) {
161
161
  try {
162
- await this.fetchBlockFromCluster(blockId);
162
+ await this.fetchBlockFromCluster(blockId, blockGets.context);
163
163
  // Re-fetch after sync
164
164
  const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
165
165
  if (refreshed[blockId]) {
@@ -175,11 +175,11 @@ export class CoordinatorRepo implements IRepo {
175
175
  return localResult;
176
176
  }
177
177
 
178
- private async fetchBlockFromCluster(blockId: BlockId): Promise<void> {
178
+ private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext): Promise<void> {
179
179
  if (!this.clusterLatestCallback) return;
180
180
 
181
181
  // Query cluster for the block
182
- const clusterLatest = await this.queryClusterForLatest(blockId);
182
+ const clusterLatest = await this.queryClusterForLatest(blockId, context);
183
183
  if (clusterLatest) {
184
184
  // Found on cluster - trigger restoration to sync the block
185
185
  await this.storageRepo.get({ blockIds: [blockId], context: { committed: [clusterLatest], rev: clusterLatest.rev } });
@@ -190,7 +190,7 @@ export class CoordinatorRepo implements IRepo {
190
190
  /**
191
191
  * Query cluster peers to find the maximum latest revision for a block.
192
192
  */
193
- private async queryClusterForLatest(blockId: BlockId): Promise<ActionRev | undefined> {
193
+ private async queryClusterForLatest(blockId: BlockId, context?: ActionContext): Promise<ActionRev | undefined> {
194
194
  const blockIdBytes = new TextEncoder().encode(blockId);
195
195
  const peers = await this.keyNetwork.findCluster(blockIdBytes);
196
196
  if (!peers || Object.keys(peers).length === 0) {
@@ -211,7 +211,7 @@ export class CoordinatorRepo implements IRepo {
211
211
  const latestResults = await Promise.allSettled(
212
212
  peerIds.map(peerIdStr => {
213
213
  const peerId = peerIdFromString(peerIdStr);
214
- return withTimeout(this.clusterLatestCallback!(peerId, blockId), 3000);
214
+ return withTimeout(this.clusterLatestCallback!(peerId, blockId, context), 3000);
215
215
  })
216
216
  );
217
217