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