@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.
- package/dist/src/cluster/block-transfer-service.d.ts +66 -0
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -0
- package/dist/src/cluster/block-transfer-service.js +166 -0
- package/dist/src/cluster/block-transfer-service.js.map +1 -0
- package/dist/src/cluster/block-transfer.d.ts +65 -0
- package/dist/src/cluster/block-transfer.d.ts.map +1 -0
- package/dist/src/cluster/block-transfer.js +208 -0
- package/dist/src/cluster/block-transfer.js.map +1 -0
- package/dist/src/cluster/cluster-repo.d.ts +23 -4
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +119 -39
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/rebalance-monitor.d.ts +64 -0
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -0
- package/dist/src/cluster/rebalance-monitor.js +157 -0
- package/dist/src/cluster/rebalance-monitor.js.map +1 -0
- package/dist/src/cluster/service.js +1 -1
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/dispute/arbitrator-selection.d.ts +10 -0
- package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -0
- package/dist/src/dispute/arbitrator-selection.js +22 -0
- package/dist/src/dispute/arbitrator-selection.js.map +1 -0
- package/dist/src/dispute/client.d.ts +17 -0
- package/dist/src/dispute/client.d.ts.map +1 -0
- package/dist/src/dispute/client.js +28 -0
- package/dist/src/dispute/client.js.map +1 -0
- package/dist/src/dispute/dispute-service.d.ts +83 -0
- package/dist/src/dispute/dispute-service.d.ts.map +1 -0
- package/dist/src/dispute/dispute-service.js +368 -0
- package/dist/src/dispute/dispute-service.js.map +1 -0
- package/dist/src/dispute/engine-health-monitor.d.ts +22 -0
- package/dist/src/dispute/engine-health-monitor.d.ts.map +1 -0
- package/dist/src/dispute/engine-health-monitor.js +75 -0
- package/dist/src/dispute/engine-health-monitor.js.map +1 -0
- package/dist/src/dispute/index.d.ts +7 -0
- package/dist/src/dispute/index.d.ts.map +1 -0
- package/dist/src/dispute/index.js +7 -0
- package/dist/src/dispute/index.js.map +1 -0
- package/dist/src/dispute/service.d.ts +41 -0
- package/dist/src/dispute/service.d.ts.map +1 -0
- package/dist/src/dispute/service.js +82 -0
- package/dist/src/dispute/service.js.map +1 -0
- package/dist/src/dispute/types.d.ts +106 -0
- package/dist/src/dispute/types.d.ts.map +1 -0
- package/dist/src/dispute/types.js +7 -0
- package/dist/src/dispute/types.js.map +1 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +23 -2
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +100 -15
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +6 -0
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +67 -13
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/logger.d.ts +1 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +2 -0
- package/dist/src/logger.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +15 -4
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +33 -20
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/protocol-client.d.ts +1 -0
- package/dist/src/protocol-client.d.ts.map +1 -1
- package/dist/src/protocol-client.js +23 -2
- package/dist/src/protocol-client.js.map +1 -1
- package/dist/src/repo/client.d.ts +1 -0
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +18 -1
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +3 -1
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +42 -2
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +20 -4
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +67 -11
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/service.d.ts +18 -2
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +88 -91
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/reputation/index.d.ts +3 -0
- package/dist/src/reputation/index.d.ts.map +1 -0
- package/dist/src/reputation/index.js +3 -0
- package/dist/src/reputation/index.js.map +1 -0
- package/dist/src/reputation/peer-reputation.d.ts +23 -0
- package/dist/src/reputation/peer-reputation.d.ts.map +1 -0
- package/dist/src/reputation/peer-reputation.js +121 -0
- package/dist/src/reputation/peer-reputation.js.map +1 -0
- package/dist/src/reputation/types.d.ts +89 -0
- package/dist/src/reputation/types.d.ts.map +1 -0
- package/dist/src/reputation/types.js +42 -0
- package/dist/src/reputation/types.js.map +1 -0
- package/dist/src/storage/arachnode-fret-adapter.d.ts +5 -0
- package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
- package/dist/src/storage/arachnode-fret-adapter.js +10 -0
- package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +5 -0
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +8 -0
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/package.json +11 -10
- package/src/cluster/block-transfer-service.ts +231 -0
- package/src/cluster/block-transfer.ts +265 -0
- package/src/cluster/cluster-repo.ts +148 -42
- package/src/cluster/rebalance-monitor.ts +223 -0
- package/src/dispute/arbitrator-selection.ts +28 -0
- package/src/dispute/client.ts +41 -0
- package/src/dispute/dispute-service.ts +456 -0
- package/src/dispute/engine-health-monitor.ts +86 -0
- package/src/dispute/index.ts +17 -0
- package/src/dispute/service.ts +119 -0
- package/src/dispute/types.ts +114 -0
- package/src/index.ts +5 -0
- package/src/libp2p-key-network.ts +120 -22
- package/src/libp2p-node-base.ts +78 -14
- package/src/logger.ts +2 -1
- package/src/network/network-manager-service.ts +47 -16
- package/src/protocol-client.ts +29 -7
- package/src/repo/client.ts +20 -6
- package/src/repo/cluster-coordinator.ts +43 -2
- package/src/repo/coordinator-repo.ts +77 -14
- package/src/repo/redirect.ts +0 -2
- package/src/repo/service.ts +95 -87
- package/src/reputation/index.ts +12 -0
- package/src/reputation/peer-reputation.ts +147 -0
- package/src/reputation/types.ts +117 -0
- package/src/storage/arachnode-fret-adapter.ts +11 -0
- package/src/storage/block-storage.ts +6 -0
- package/src/storage/storage-repo.ts +9 -0
- package/dist/index.min.js +0 -53
- 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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
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:
|
|
316
|
-
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
405
|
-
if (promiseCount
|
|
406
|
-
return TransactionPhase.
|
|
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:
|
|
428
|
-
: { type: 'reject', signature:
|
|
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:
|
|
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:
|
|
755
|
-
rejectReason
|
|
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
|
+
}
|