@optimystic/db-p2p 0.3.0 → 0.9.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.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +4 -1
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +2 -16
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +68 -71
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +7 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +11 -1
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +53 -18
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +3 -5
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +2 -0
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +8 -2
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +5 -3
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.js +1 -1
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +2 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +39 -17
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +2 -2
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +6 -6
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/sync/service.d.ts +7 -7
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +42 -73
- package/dist/src/sync/service.js.map +1 -1
- package/package.json +5 -4
- package/src/cluster/block-transfer-service.ts +4 -1
- package/src/cluster/block-transfer.ts +80 -99
- package/src/cluster/client.ts +6 -1
- package/src/cluster/cluster-repo.ts +72 -17
- package/src/cluster/rebalance-monitor.ts +3 -5
- package/src/dispute/dispute-service.ts +9 -3
- package/src/index.ts +2 -0
- package/src/libp2p-key-network.ts +5 -3
- package/src/libp2p-node-base.ts +1 -1
- package/src/repo/cluster-coordinator.ts +44 -18
- package/src/repo/coordinator-repo.ts +8 -8
- package/src/sync/service.ts +62 -96
|
@@ -1,28 +1,13 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
118
|
+
blockId: string,
|
|
143
119
|
succeeded: string[],
|
|
144
120
|
failed: string[]
|
|
145
121
|
): Promise<void> {
|
|
146
|
-
const key = `pull:${
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
this.
|
|
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',
|
|
160
|
-
succeeded.push(
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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:${
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
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
|
-
|
|
244
|
-
|
|
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);
|
package/src/cluster/client.ts
CHANGED
|
@@ -23,7 +23,12 @@ export class ClusterClient extends ProtocolClient implements ICluster {
|
|
|
23
23
|
response = await this.processMessage<any>(message, preferred)
|
|
24
24
|
} catch (err) {
|
|
25
25
|
if (preferred !== '/db-p2p/cluster/1.0.0') {
|
|
26
|
-
|
|
26
|
+
try {
|
|
27
|
+
response = await this.processMessage<any>(message, '/db-p2p/cluster/1.0.0')
|
|
28
|
+
} catch (fallbackErr) {
|
|
29
|
+
// Throw original error - fallback protocol is likely not registered
|
|
30
|
+
throw err
|
|
31
|
+
}
|
|
27
32
|
} else {
|
|
28
33
|
throw err
|
|
29
34
|
}
|
|
@@ -237,15 +237,8 @@ export class ClusterMember implements ICluster {
|
|
|
237
237
|
log('cluster-member:action-consensus', {
|
|
238
238
|
messageHash: record.messageHash
|
|
239
239
|
});
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
if (inboundPhase !== 'commit') {
|
|
243
|
-
await this.handleConsensus(currentRecord);
|
|
244
|
-
} else {
|
|
245
|
-
log('cluster-member:consensus-skip-already-committed', {
|
|
246
|
-
messageHash: record.messageHash
|
|
247
|
-
});
|
|
248
|
-
}
|
|
240
|
+
// handleConsensus has its own idempotency guard via executedTransactions
|
|
241
|
+
await this.handleConsensus(currentRecord);
|
|
249
242
|
// Don't call clearTransaction here - it happens in handleConsensus
|
|
250
243
|
shouldPersist = false;
|
|
251
244
|
break;
|
|
@@ -306,7 +299,8 @@ export class ClusterMember implements ICluster {
|
|
|
306
299
|
}
|
|
307
300
|
|
|
308
301
|
/**
|
|
309
|
-
* Merges two records, validating that non-signature fields match
|
|
302
|
+
* Merges two records, validating that non-signature fields match.
|
|
303
|
+
* Detects equivocation (same peer changing vote type) and applies penalties.
|
|
310
304
|
*/
|
|
311
305
|
private async mergeRecords(existing: ClusterRecord, incoming: ClusterRecord): Promise<ClusterRecord> {
|
|
312
306
|
log('cluster-member:merge-records', {
|
|
@@ -327,14 +321,64 @@ export class ClusterMember implements ICluster {
|
|
|
327
321
|
throw new Error('Peers mismatch');
|
|
328
322
|
}
|
|
329
323
|
|
|
330
|
-
// Merge signatures
|
|
324
|
+
// Merge signatures with equivocation detection
|
|
325
|
+
const mergedPromises = this.detectEquivocation(
|
|
326
|
+
existing.promises, incoming.promises, 'promise', existing.messageHash
|
|
327
|
+
);
|
|
328
|
+
const mergedCommits = this.detectEquivocation(
|
|
329
|
+
existing.commits, incoming.commits, 'commit', existing.messageHash
|
|
330
|
+
);
|
|
331
|
+
|
|
331
332
|
return {
|
|
332
333
|
...existing,
|
|
333
|
-
promises:
|
|
334
|
-
commits:
|
|
334
|
+
promises: mergedPromises,
|
|
335
|
+
commits: mergedCommits
|
|
335
336
|
};
|
|
336
337
|
}
|
|
337
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Compares existing vs incoming signatures for the same peers.
|
|
341
|
+
* If a peer's vote type changed (approve↔reject), that's equivocation:
|
|
342
|
+
* report a penalty and keep the first-seen signature.
|
|
343
|
+
* New peers are accepted normally.
|
|
344
|
+
*/
|
|
345
|
+
private detectEquivocation(
|
|
346
|
+
existing: Record<string, Signature>,
|
|
347
|
+
incoming: Record<string, Signature>,
|
|
348
|
+
phase: 'promise' | 'commit',
|
|
349
|
+
messageHash: string
|
|
350
|
+
): Record<string, Signature> {
|
|
351
|
+
const merged = { ...existing };
|
|
352
|
+
|
|
353
|
+
for (const [peerId, incomingSig] of Object.entries(incoming)) {
|
|
354
|
+
const existingSig = existing[peerId];
|
|
355
|
+
if (existingSig) {
|
|
356
|
+
if (existingSig.type !== incomingSig.type) {
|
|
357
|
+
// Equivocation detected: peer changed their vote type
|
|
358
|
+
log('cluster-member:equivocation-detected', {
|
|
359
|
+
peerId,
|
|
360
|
+
phase,
|
|
361
|
+
messageHash,
|
|
362
|
+
existingType: existingSig.type,
|
|
363
|
+
incomingType: incomingSig.type
|
|
364
|
+
});
|
|
365
|
+
this.reputation?.reportPeer(
|
|
366
|
+
peerId,
|
|
367
|
+
PenaltyReason.Equivocation,
|
|
368
|
+
`${phase}:${messageHash}:${existingSig.type}->${incomingSig.type}`
|
|
369
|
+
);
|
|
370
|
+
// Keep first-seen signature — do not let the peer flip their vote
|
|
371
|
+
}
|
|
372
|
+
// Same type: keep existing (no-op, already in merged)
|
|
373
|
+
} else {
|
|
374
|
+
// New peer — accept normally
|
|
375
|
+
merged[peerId] = incomingSig;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return merged;
|
|
380
|
+
}
|
|
381
|
+
|
|
338
382
|
private async validateRecord(record: ClusterRecord): Promise<void> {
|
|
339
383
|
// Validate message hash matches the message content
|
|
340
384
|
const expectedHash = await this.computeMessageHash(record.message);
|
|
@@ -356,7 +400,7 @@ export class ClusterMember implements ICluster {
|
|
|
356
400
|
* Must match cluster-coordinator.ts createMessageHash().
|
|
357
401
|
*/
|
|
358
402
|
private async computeMessageHash(message: RepoMessage): Promise<string> {
|
|
359
|
-
const msgBytes = new TextEncoder().encode(
|
|
403
|
+
const msgBytes = new TextEncoder().encode(ClusterMember.canonicalJson(message));
|
|
360
404
|
const hashBytes = await sha256.digest(msgBytes);
|
|
361
405
|
return base58btc.encode(hashBytes.digest);
|
|
362
406
|
}
|
|
@@ -386,14 +430,23 @@ export class ClusterMember implements ICluster {
|
|
|
386
430
|
}
|
|
387
431
|
}
|
|
388
432
|
|
|
433
|
+
/** Deterministic JSON: sorts object keys so hash is order-independent */
|
|
434
|
+
private static canonicalJson(value: unknown): string {
|
|
435
|
+
return JSON.stringify(value, (_, v) =>
|
|
436
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
437
|
+
? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
|
|
438
|
+
: v
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
389
442
|
private async computePromiseHash(record: ClusterRecord): Promise<string> {
|
|
390
|
-
const msgBytes = new TextEncoder().encode(record.messageHash +
|
|
443
|
+
const msgBytes = new TextEncoder().encode(record.messageHash + ClusterMember.canonicalJson(record.message));
|
|
391
444
|
const hashBytes = await sha256.digest(msgBytes);
|
|
392
445
|
return uint8ArrayToString(hashBytes.digest, 'base64url');
|
|
393
446
|
}
|
|
394
447
|
|
|
395
448
|
private async computeCommitHash(record: ClusterRecord): Promise<string> {
|
|
396
|
-
const msgBytes = new TextEncoder().encode(record.messageHash +
|
|
449
|
+
const msgBytes = new TextEncoder().encode(record.messageHash + ClusterMember.canonicalJson(record.message) + ClusterMember.canonicalJson(record.promises));
|
|
397
450
|
const hashBytes = await sha256.digest(msgBytes);
|
|
398
451
|
return uint8ArrayToString(hashBytes.digest, 'base64url');
|
|
399
452
|
}
|
|
@@ -414,7 +467,9 @@ export class ClusterMember implements ICluster {
|
|
|
414
467
|
if (!peerInfo?.publicKey?.length) {
|
|
415
468
|
throw new Error(`No public key for peer ${peerId}`);
|
|
416
469
|
}
|
|
417
|
-
|
|
470
|
+
// publicKey is base64url-encoded string (JSON-serialization safe)
|
|
471
|
+
const keyBytes = uint8ArrayFromString(peerInfo.publicKey, 'base64url');
|
|
472
|
+
const pubKey = publicKeyFromRaw(keyBytes);
|
|
418
473
|
const payload = this.computeSigningPayload(hash, signature.type, signature.rejectReason);
|
|
419
474
|
const sigBytes = uint8ArrayFromString(signature.signature, 'base64url');
|
|
420
475
|
return pubKey.verify(payload, sigBytes);
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
|
@@ -434,11 +437,14 @@ export class DisputeService {
|
|
|
434
437
|
private async verifyDisputeSignature(
|
|
435
438
|
disputeId: string,
|
|
436
439
|
signature: string,
|
|
437
|
-
publicKey?: Uint8Array
|
|
440
|
+
publicKey?: string | Uint8Array
|
|
438
441
|
): Promise<boolean> {
|
|
439
442
|
if (!publicKey?.length) return false;
|
|
440
443
|
try {
|
|
441
|
-
const
|
|
444
|
+
const keyBytes = typeof publicKey === 'string'
|
|
445
|
+
? uint8ArrayFromString(publicKey, 'base64url')
|
|
446
|
+
: publicKey;
|
|
447
|
+
const pubKey = publicKeyFromRaw(keyBytes);
|
|
442
448
|
const payload = new TextEncoder().encode(disputeId);
|
|
443
449
|
const sigBytes = uint8ArrayFromString(signature, 'base64url');
|
|
444
450
|
return pubKey.verify(payload, sigBytes);
|
|
@@ -448,6 +454,6 @@ export class DisputeService {
|
|
|
448
454
|
}
|
|
449
455
|
|
|
450
456
|
private findChallengeForDispute(disputeId: string): DisputeChallenge | undefined {
|
|
451
|
-
return this.activeDisputes.get(disputeId);
|
|
457
|
+
return this.activeDisputes.get(disputeId) ?? this.resolvedChallenges.get(disputeId);
|
|
452
458
|
}
|
|
453
459
|
}
|
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";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AbortOptions, Libp2p, PeerId, Stream } from "@libp2p/interface";
|
|
2
|
-
import { toString as u8ToString } from 'uint8arrays
|
|
2
|
+
import { toString as u8ToString, fromString as u8FromString } from 'uint8arrays'
|
|
3
3
|
import type { ClusterPeers, FindCoordinatorOptions, IKeyNetwork, IPeerNetwork } from "@optimystic/db-core";
|
|
4
4
|
import { peerIdFromString } from '@libp2p/peer-id'
|
|
5
5
|
import { multiaddr } from '@multiformats/multiaddr'
|
|
@@ -416,12 +416,14 @@ export class Libp2pKeyPeerNetwork implements IKeyNetwork, IPeerNetwork {
|
|
|
416
416
|
|
|
417
417
|
for (const idStr of ids) {
|
|
418
418
|
if (idStr === this.libp2p.peerId.toString()) {
|
|
419
|
-
|
|
419
|
+
const raw = this.libp2p.peerId.publicKey?.raw ?? new Uint8Array()
|
|
420
|
+
peers[idStr] = { multiaddrs: this.libp2p.getMultiaddrs().map(ma => ma.toString()), publicKey: u8ToString(raw, 'base64url') }
|
|
420
421
|
continue
|
|
421
422
|
}
|
|
422
423
|
const strings = connectedByPeer[idStr] ?? []
|
|
423
424
|
const remotePeerId = peerIdFromString(idStr)
|
|
424
|
-
|
|
425
|
+
const raw = remotePeerId.publicKey?.raw ?? new Uint8Array()
|
|
426
|
+
peers[idStr] = { multiaddrs: this.parseMultiaddrs(strings), publicKey: u8ToString(raw, 'base64url') }
|
|
425
427
|
}
|
|
426
428
|
|
|
427
429
|
this.log('findCluster:done key=%s ms=%d peers=%d',
|
package/src/libp2p-node-base.ts
CHANGED
|
@@ -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 });
|
|
@@ -58,8 +58,17 @@ export class ClusterCoordinator {
|
|
|
58
58
|
/**
|
|
59
59
|
* Creates a base 58 BTC string hash for a message to uniquely identify a transaction
|
|
60
60
|
*/
|
|
61
|
+
/** Deterministic JSON: sorts object keys so hash is order-independent */
|
|
62
|
+
private static canonicalJson(value: unknown): string {
|
|
63
|
+
return JSON.stringify(value, (_, v) =>
|
|
64
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
65
|
+
? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
|
|
66
|
+
: v
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
private async createMessageHash(message: RepoMessage): Promise<string> {
|
|
62
|
-
const msgBytes = new TextEncoder().encode(
|
|
71
|
+
const msgBytes = new TextEncoder().encode(ClusterCoordinator.canonicalJson(message));
|
|
63
72
|
const hashBytes = await sha256.digest(msgBytes);
|
|
64
73
|
return base58btc.encode(hashBytes.digest);
|
|
65
74
|
}
|
|
@@ -511,26 +520,43 @@ export class ClusterCoordinator {
|
|
|
511
520
|
peerCount,
|
|
512
521
|
threshold: this.cfg.simpleMajorityThreshold
|
|
513
522
|
});
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
523
|
+
// Broadcast the merged record (with all commit signatures) to ALL peers
|
|
524
|
+
// so each peer can independently reach consensus and execute the operations.
|
|
525
|
+
// Without this, only the coordinator's local cluster executes — remote peers
|
|
526
|
+
// never see enough commits to reach consensus on their own.
|
|
527
|
+
const broadcastResults = await Promise.allSettled(
|
|
528
|
+
peerIds.map(peerIdStr => {
|
|
529
|
+
const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
|
|
530
|
+
return isLocal
|
|
531
|
+
? this.localCluster!.update(record)
|
|
532
|
+
: this.createClusterClient(peerIdFromString(peerIdStr)).update(record).catch(err => {
|
|
533
|
+
log('cluster-tx:consensus-broadcast-error', { messageHash: record.messageHash, peerId: peerIdStr, error: (err as Error).message });
|
|
534
|
+
return null;
|
|
535
|
+
});
|
|
536
|
+
})
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// Check for broadcast failures (excluding local)
|
|
540
|
+
const broadcastFailures: string[] = [];
|
|
541
|
+
broadcastResults.forEach((result, idx) => {
|
|
542
|
+
const peerId = peerIds[idx]!;
|
|
543
|
+
const isLocal = this.localCluster && peerId === this.localCluster.peerId.toString();
|
|
544
|
+
if (!isLocal && (result.status === 'rejected' || result.value === null)) {
|
|
545
|
+
broadcastFailures.push(peerId);
|
|
525
546
|
}
|
|
547
|
+
});
|
|
548
|
+
if (broadcastFailures.length > 0) {
|
|
549
|
+
this.scheduleCommitRetry(record.messageHash, record, broadcastFailures);
|
|
550
|
+
} else {
|
|
551
|
+
this.clearRetry(record.messageHash);
|
|
526
552
|
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const missingPeers = commitFailures.map(entry => entry.peerId);
|
|
530
|
-
if (missingPeers.length > 0) {
|
|
531
|
-
this.scheduleCommitRetry(record.messageHash, record, missingPeers);
|
|
532
553
|
} else {
|
|
533
|
-
|
|
554
|
+
const missingPeers = commitFailures.map(entry => entry.peerId);
|
|
555
|
+
if (missingPeers.length > 0) {
|
|
556
|
+
this.scheduleCommitRetry(record.messageHash, record, missingPeers);
|
|
557
|
+
} else {
|
|
558
|
+
this.clearRetry(record.messageHash);
|
|
559
|
+
}
|
|
534
560
|
}
|
|
535
561
|
return record;
|
|
536
562
|
}
|
|
@@ -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) {
|
|
@@ -207,11 +207,11 @@ export class CoordinatorRepo implements IRepo {
|
|
|
207
207
|
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), timeoutMs))
|
|
208
208
|
]);
|
|
209
209
|
|
|
210
|
-
// Query peers in parallel for their latest revision (with
|
|
210
|
+
// Query peers in parallel for their latest revision (with 1s timeout per peer)
|
|
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),
|
|
214
|
+
return withTimeout(this.clusterLatestCallback!(peerId, blockId, context), 1000);
|
|
215
215
|
})
|
|
216
216
|
);
|
|
217
217
|
|