@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.
- 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 +163 -0
- package/dist/src/cluster/block-transfer-service.js.map +1 -0
- package/dist/src/cluster/block-transfer.d.ts +79 -0
- package/dist/src/cluster/block-transfer.d.ts.map +1 -0
- package/dist/src/cluster/block-transfer.js +211 -0
- package/dist/src/cluster/block-transfer.js.map +1 -0
- package/dist/src/cluster/cluster-repo.d.ts +14 -3
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +80 -35
- 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 +159 -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 +81 -0
- package/dist/src/dispute/dispute-service.d.ts.map +1 -0
- package/dist/src/dispute/dispute-service.js +365 -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 +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -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 +66 -12
- 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 +18 -2
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +62 -6
- 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 +228 -0
- package/src/cluster/block-transfer.ts +284 -0
- package/src/cluster/cluster-repo.ts +93 -38
- package/src/cluster/rebalance-monitor.ts +225 -0
- package/src/dispute/arbitrator-selection.ts +28 -0
- package/src/dispute/client.ts +41 -0
- package/src/dispute/dispute-service.ts +453 -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 +3 -0
- package/src/libp2p-key-network.ts +120 -22
- package/src/libp2p-node-base.ts +77 -13
- 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 +70 -7
- 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
|
|
|
@@ -344,20 +362,27 @@ export class ClusterMember implements ICluster {
|
|
|
344
362
|
}
|
|
345
363
|
|
|
346
364
|
private async validateSignatures(record: ClusterRecord): Promise<void> {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
405
|
-
if (promiseCount
|
|
406
|
-
return TransactionPhase.
|
|
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:
|
|
428
|
-
: { type: 'reject', signature:
|
|
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:
|
|
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:
|
|
755
|
-
rejectReason
|
|
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
|
+
}
|