@ruvector/edge-net 0.4.6 → 0.5.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/package.json +13 -2
- package/plugins/cli.js +395 -0
- package/plugins/implementations/compression.js +132 -0
- package/plugins/implementations/e2e-encryption.js +160 -0
- package/plugins/implementations/federated-learning.js +249 -0
- package/plugins/implementations/reputation-staking.js +243 -0
- package/plugins/implementations/swarm-intelligence.js +386 -0
- package/plugins/index.js +90 -0
- package/plugins/plugin-loader.js +440 -0
- package/plugins/plugin-manifest.js +702 -0
- package/plugins/plugin-sdk.js +496 -0
- package/tests/plugin-system-test.js +382 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-End Encryption Plugin
|
|
3
|
+
*
|
|
4
|
+
* X25519 key exchange + ChaCha20-Poly1305 encryption.
|
|
5
|
+
* Provides forward secrecy with automatic key rotation.
|
|
6
|
+
*
|
|
7
|
+
* @module @ruvector/edge-net/plugins/e2e-encryption
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'crypto';
|
|
11
|
+
|
|
12
|
+
export class E2EEncryptionPlugin {
|
|
13
|
+
constructor(config = {}) {
|
|
14
|
+
this.config = {
|
|
15
|
+
keyRotationInterval: config.keyRotationInterval || 3600000, // 1 hour
|
|
16
|
+
forwardSecrecy: config.forwardSecrecy ?? true,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Session keys (in production, use proper X25519)
|
|
20
|
+
this.sessionKeys = new Map(); // peerId -> { key, iv, createdAt }
|
|
21
|
+
this.rotationTimer = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async init() {
|
|
25
|
+
if (this.config.forwardSecrecy) {
|
|
26
|
+
this.rotationTimer = setInterval(
|
|
27
|
+
() => this._rotateKeys(),
|
|
28
|
+
this.config.keyRotationInterval
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async destroy() {
|
|
34
|
+
if (this.rotationTimer) {
|
|
35
|
+
clearInterval(this.rotationTimer);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Establish encrypted session with peer
|
|
41
|
+
*/
|
|
42
|
+
async establishSession(peerId, peerPublicKey) {
|
|
43
|
+
// In production: X25519 key exchange
|
|
44
|
+
// For demo: Generate shared secret from peer ID
|
|
45
|
+
const sharedSecret = createHash('sha256')
|
|
46
|
+
.update(peerId + '-' + Date.now())
|
|
47
|
+
.digest();
|
|
48
|
+
|
|
49
|
+
const sessionKey = {
|
|
50
|
+
key: sharedSecret,
|
|
51
|
+
iv: randomBytes(16),
|
|
52
|
+
createdAt: Date.now(),
|
|
53
|
+
messageCount: 0,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
this.sessionKeys.set(peerId, sessionKey);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
sessionId: createHash('sha256').update(sharedSecret).digest('hex').slice(0, 16),
|
|
60
|
+
publicKey: randomBytes(32).toString('hex'), // Our ephemeral public key
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Encrypt message for peer
|
|
66
|
+
*/
|
|
67
|
+
encrypt(peerId, plaintext) {
|
|
68
|
+
const session = this.sessionKeys.get(peerId);
|
|
69
|
+
if (!session) {
|
|
70
|
+
throw new Error(`No session with peer: ${peerId}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Use AES-256-GCM (ChaCha20-Poly1305 in production)
|
|
74
|
+
const iv = randomBytes(12);
|
|
75
|
+
const cipher = createCipheriv('aes-256-gcm', session.key, iv);
|
|
76
|
+
|
|
77
|
+
const data = typeof plaintext === 'string' ? plaintext : JSON.stringify(plaintext);
|
|
78
|
+
const encrypted = Buffer.concat([
|
|
79
|
+
cipher.update(data, 'utf8'),
|
|
80
|
+
cipher.final(),
|
|
81
|
+
]);
|
|
82
|
+
const authTag = cipher.getAuthTag();
|
|
83
|
+
|
|
84
|
+
session.messageCount++;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
iv: iv.toString('base64'),
|
|
88
|
+
ciphertext: encrypted.toString('base64'),
|
|
89
|
+
authTag: authTag.toString('base64'),
|
|
90
|
+
messageNum: session.messageCount,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decrypt message from peer
|
|
96
|
+
*/
|
|
97
|
+
decrypt(peerId, encryptedMessage) {
|
|
98
|
+
const session = this.sessionKeys.get(peerId);
|
|
99
|
+
if (!session) {
|
|
100
|
+
throw new Error(`No session with peer: ${peerId}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const iv = Buffer.from(encryptedMessage.iv, 'base64');
|
|
104
|
+
const ciphertext = Buffer.from(encryptedMessage.ciphertext, 'base64');
|
|
105
|
+
const authTag = Buffer.from(encryptedMessage.authTag, 'base64');
|
|
106
|
+
|
|
107
|
+
const decipher = createDecipheriv('aes-256-gcm', session.key, iv);
|
|
108
|
+
decipher.setAuthTag(authTag);
|
|
109
|
+
|
|
110
|
+
const decrypted = Buffer.concat([
|
|
111
|
+
decipher.update(ciphertext),
|
|
112
|
+
decipher.final(),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
return decrypted.toString('utf8');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Rotate session keys for forward secrecy
|
|
120
|
+
*/
|
|
121
|
+
_rotateKeys() {
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
for (const [peerId, session] of this.sessionKeys) {
|
|
124
|
+
if (now - session.createdAt > this.config.keyRotationInterval) {
|
|
125
|
+
// Generate new session key
|
|
126
|
+
const newKey = createHash('sha256')
|
|
127
|
+
.update(session.key)
|
|
128
|
+
.update(randomBytes(32))
|
|
129
|
+
.digest();
|
|
130
|
+
|
|
131
|
+
session.key = newKey;
|
|
132
|
+
session.createdAt = now;
|
|
133
|
+
session.messageCount = 0;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if session exists
|
|
140
|
+
*/
|
|
141
|
+
hasSession(peerId) {
|
|
142
|
+
return this.sessionKeys.has(peerId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* End session with peer
|
|
147
|
+
*/
|
|
148
|
+
endSession(peerId) {
|
|
149
|
+
return this.sessionKeys.delete(peerId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getStats() {
|
|
153
|
+
return {
|
|
154
|
+
activeSessions: this.sessionKeys.size,
|
|
155
|
+
rotationInterval: this.config.keyRotationInterval,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default E2EEncryptionPlugin;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Federated Learning Plugin
|
|
3
|
+
*
|
|
4
|
+
* Train ML models across nodes without sharing raw data.
|
|
5
|
+
* Implements FedAvg with differential privacy.
|
|
6
|
+
*
|
|
7
|
+
* @module @ruvector/edge-net/plugins/federated-learning
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
import { randomBytes } from 'crypto';
|
|
12
|
+
|
|
13
|
+
export class FederatedLearningPlugin extends EventEmitter {
|
|
14
|
+
constructor(config = {}) {
|
|
15
|
+
super();
|
|
16
|
+
|
|
17
|
+
this.config = {
|
|
18
|
+
aggregationStrategy: config.aggregationStrategy || 'fedavg',
|
|
19
|
+
localEpochs: config.localEpochs || 5,
|
|
20
|
+
differentialPrivacy: config.differentialPrivacy ?? true,
|
|
21
|
+
noiseMultiplier: config.noiseMultiplier || 1.0,
|
|
22
|
+
minParticipants: config.minParticipants || 3,
|
|
23
|
+
roundTimeout: config.roundTimeout || 60000,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
this.rounds = new Map(); // roundId -> RoundState
|
|
27
|
+
this.localModels = new Map(); // modelId -> weights
|
|
28
|
+
this.globalModels = new Map(); // modelId -> aggregated weights
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start a new training round
|
|
33
|
+
*/
|
|
34
|
+
startRound(modelId, globalWeights) {
|
|
35
|
+
const roundId = `round-${Date.now()}-${randomBytes(4).toString('hex')}`;
|
|
36
|
+
|
|
37
|
+
const round = {
|
|
38
|
+
id: roundId,
|
|
39
|
+
modelId,
|
|
40
|
+
globalWeights,
|
|
41
|
+
participants: new Map(),
|
|
42
|
+
status: 'collecting',
|
|
43
|
+
startedAt: Date.now(),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
this.rounds.set(roundId, round);
|
|
47
|
+
this.emit('round:started', { roundId, modelId });
|
|
48
|
+
|
|
49
|
+
// Set timeout
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
if (round.status === 'collecting') {
|
|
52
|
+
this._aggregateRound(roundId);
|
|
53
|
+
}
|
|
54
|
+
}, this.config.roundTimeout);
|
|
55
|
+
|
|
56
|
+
return roundId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Train locally and submit update
|
|
61
|
+
*/
|
|
62
|
+
async trainLocal(roundId, localData, options = {}) {
|
|
63
|
+
const round = this.rounds.get(roundId);
|
|
64
|
+
if (!round) {
|
|
65
|
+
throw new Error(`Round not found: ${roundId}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Simulate local training
|
|
69
|
+
const localUpdate = await this._performLocalTraining(
|
|
70
|
+
round.globalWeights,
|
|
71
|
+
localData,
|
|
72
|
+
options.epochs || this.config.localEpochs
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Add differential privacy noise if enabled
|
|
76
|
+
if (this.config.differentialPrivacy) {
|
|
77
|
+
this._addDifferentialPrivacy(localUpdate);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Submit update
|
|
81
|
+
const participantId = options.participantId || randomBytes(8).toString('hex');
|
|
82
|
+
round.participants.set(participantId, {
|
|
83
|
+
update: localUpdate,
|
|
84
|
+
dataSize: localData.length,
|
|
85
|
+
submittedAt: Date.now(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
this.emit('update:submitted', { roundId, participantId });
|
|
89
|
+
|
|
90
|
+
// Check if we have enough participants
|
|
91
|
+
if (round.participants.size >= this.config.minParticipants) {
|
|
92
|
+
this._aggregateRound(roundId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { participantId, updateSize: localUpdate.length };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Perform local training (simulated)
|
|
100
|
+
*/
|
|
101
|
+
async _performLocalTraining(globalWeights, localData, epochs) {
|
|
102
|
+
// In production: Use ONNX Runtime or TensorFlow.js
|
|
103
|
+
// For demo: Simulate gradient descent
|
|
104
|
+
const weights = globalWeights ? [...globalWeights] : Array(10).fill(0);
|
|
105
|
+
|
|
106
|
+
for (let epoch = 0; epoch < epochs; epoch++) {
|
|
107
|
+
for (const sample of localData) {
|
|
108
|
+
// Simplified SGD update
|
|
109
|
+
for (let i = 0; i < weights.length; i++) {
|
|
110
|
+
const gradient = (sample.features?.[i] || Math.random()) * 0.01;
|
|
111
|
+
weights[i] -= gradient;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return weights;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Add differential privacy noise
|
|
121
|
+
*/
|
|
122
|
+
_addDifferentialPrivacy(weights) {
|
|
123
|
+
const sigma = this.config.noiseMultiplier;
|
|
124
|
+
for (let i = 0; i < weights.length; i++) {
|
|
125
|
+
// Gaussian noise
|
|
126
|
+
const u1 = Math.random();
|
|
127
|
+
const u2 = Math.random();
|
|
128
|
+
const noise = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
|
129
|
+
weights[i] += noise * sigma;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Aggregate updates using FedAvg
|
|
135
|
+
*/
|
|
136
|
+
_aggregateRound(roundId) {
|
|
137
|
+
const round = this.rounds.get(roundId);
|
|
138
|
+
if (!round || round.status !== 'collecting') {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
round.status = 'aggregating';
|
|
143
|
+
|
|
144
|
+
const updates = Array.from(round.participants.values());
|
|
145
|
+
if (updates.length === 0) {
|
|
146
|
+
round.status = 'failed';
|
|
147
|
+
this.emit('round:failed', { roundId, reason: 'No updates' });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let aggregatedWeights;
|
|
152
|
+
|
|
153
|
+
switch (this.config.aggregationStrategy) {
|
|
154
|
+
case 'fedavg':
|
|
155
|
+
aggregatedWeights = this._fedAvg(updates);
|
|
156
|
+
break;
|
|
157
|
+
case 'fedprox':
|
|
158
|
+
aggregatedWeights = this._fedProx(updates, round.globalWeights);
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
aggregatedWeights = this._fedAvg(updates);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Store aggregated model
|
|
165
|
+
this.globalModels.set(round.modelId, aggregatedWeights);
|
|
166
|
+
round.status = 'completed';
|
|
167
|
+
round.aggregatedWeights = aggregatedWeights;
|
|
168
|
+
round.completedAt = Date.now();
|
|
169
|
+
|
|
170
|
+
this.emit('round:completed', {
|
|
171
|
+
roundId,
|
|
172
|
+
modelId: round.modelId,
|
|
173
|
+
participants: round.participants.size,
|
|
174
|
+
duration: round.completedAt - round.startedAt,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return aggregatedWeights;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* FedAvg aggregation
|
|
182
|
+
*/
|
|
183
|
+
_fedAvg(updates) {
|
|
184
|
+
if (updates.length === 0) return null;
|
|
185
|
+
|
|
186
|
+
const totalSamples = updates.reduce((sum, u) => sum + u.dataSize, 0);
|
|
187
|
+
const numWeights = updates[0].update.length;
|
|
188
|
+
const aggregated = Array(numWeights).fill(0);
|
|
189
|
+
|
|
190
|
+
for (const { update, dataSize } of updates) {
|
|
191
|
+
const weight = dataSize / totalSamples;
|
|
192
|
+
for (let i = 0; i < numWeights; i++) {
|
|
193
|
+
aggregated[i] += update[i] * weight;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return aggregated;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* FedProx aggregation (with proximal term)
|
|
202
|
+
*/
|
|
203
|
+
_fedProx(updates, globalWeights) {
|
|
204
|
+
const fedAvgResult = this._fedAvg(updates);
|
|
205
|
+
if (!globalWeights) return fedAvgResult;
|
|
206
|
+
|
|
207
|
+
// Add proximal regularization
|
|
208
|
+
const mu = 0.01; // Proximal strength
|
|
209
|
+
for (let i = 0; i < fedAvgResult.length; i++) {
|
|
210
|
+
fedAvgResult[i] = (1 - mu) * fedAvgResult[i] + mu * globalWeights[i];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return fedAvgResult;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get current global model
|
|
218
|
+
*/
|
|
219
|
+
getGlobalModel(modelId) {
|
|
220
|
+
return this.globalModels.get(modelId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get round status
|
|
225
|
+
*/
|
|
226
|
+
getRoundStatus(roundId) {
|
|
227
|
+
const round = this.rounds.get(roundId);
|
|
228
|
+
if (!round) return null;
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
id: round.id,
|
|
232
|
+
modelId: round.modelId,
|
|
233
|
+
status: round.status,
|
|
234
|
+
participants: round.participants.size,
|
|
235
|
+
startedAt: round.startedAt,
|
|
236
|
+
completedAt: round.completedAt,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
getStats() {
|
|
241
|
+
return {
|
|
242
|
+
totalRounds: this.rounds.size,
|
|
243
|
+
globalModels: this.globalModels.size,
|
|
244
|
+
config: this.config,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export default FederatedLearningPlugin;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reputation Staking Plugin
|
|
3
|
+
*
|
|
4
|
+
* Stake credits as collateral for good behavior.
|
|
5
|
+
* Slashing mechanism for misbehavior detection.
|
|
6
|
+
*
|
|
7
|
+
* @module @ruvector/edge-net/plugins/reputation-staking
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
|
|
12
|
+
export class ReputationStakingPlugin extends EventEmitter {
|
|
13
|
+
constructor(config = {}) {
|
|
14
|
+
super();
|
|
15
|
+
|
|
16
|
+
this.config = {
|
|
17
|
+
minStake: config.minStake || 10,
|
|
18
|
+
slashRate: config.slashRate || 0.1, // 10% slash
|
|
19
|
+
unbondingPeriod: config.unbondingPeriod || 604800000, // 7 days
|
|
20
|
+
maxSlashPerPeriod: config.maxSlashPerPeriod || 0.5, // Max 50% slash per period
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Staking state
|
|
24
|
+
this.stakes = new Map(); // nodeId -> { staked, reputation, unbonding }
|
|
25
|
+
this.slashHistory = new Map(); // nodeId -> [{ reason, amount, timestamp }]
|
|
26
|
+
this.unbondingQueue = []; // [{ nodeId, amount, availableAt }]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stake credits
|
|
31
|
+
*/
|
|
32
|
+
stake(nodeId, amount, creditSystem) {
|
|
33
|
+
if (amount < this.config.minStake) {
|
|
34
|
+
throw new Error(`Minimum stake is ${this.config.minStake}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check balance
|
|
38
|
+
const balance = creditSystem.getBalance(nodeId);
|
|
39
|
+
if (balance < amount) {
|
|
40
|
+
throw new Error(`Insufficient balance: ${balance} < ${amount}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Lock credits
|
|
44
|
+
creditSystem.spendCredits(nodeId, amount, `stake-${Date.now()}`);
|
|
45
|
+
|
|
46
|
+
// Create or update stake
|
|
47
|
+
let stake = this.stakes.get(nodeId);
|
|
48
|
+
if (!stake) {
|
|
49
|
+
stake = {
|
|
50
|
+
staked: 0,
|
|
51
|
+
reputation: 100, // Start at 100
|
|
52
|
+
unbonding: 0,
|
|
53
|
+
lastActivity: Date.now(),
|
|
54
|
+
successfulTasks: 0,
|
|
55
|
+
failedTasks: 0,
|
|
56
|
+
};
|
|
57
|
+
this.stakes.set(nodeId, stake);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
stake.staked += amount;
|
|
61
|
+
stake.lastActivity = Date.now();
|
|
62
|
+
|
|
63
|
+
this.emit('staked', { nodeId, amount, totalStaked: stake.staked });
|
|
64
|
+
|
|
65
|
+
return stake;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Request unstaking (enters unbonding period)
|
|
70
|
+
*/
|
|
71
|
+
unstake(nodeId, amount) {
|
|
72
|
+
const stake = this.stakes.get(nodeId);
|
|
73
|
+
if (!stake) {
|
|
74
|
+
throw new Error(`No stake found for: ${nodeId}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (amount > stake.staked) {
|
|
78
|
+
throw new Error(`Cannot unstake more than staked: ${stake.staked}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stake.staked -= amount;
|
|
82
|
+
stake.unbonding += amount;
|
|
83
|
+
|
|
84
|
+
const availableAt = Date.now() + this.config.unbondingPeriod;
|
|
85
|
+
this.unbondingQueue.push({ nodeId, amount, availableAt });
|
|
86
|
+
|
|
87
|
+
this.emit('unstaking', { nodeId, amount, availableAt });
|
|
88
|
+
|
|
89
|
+
return { unbonding: stake.unbonding, availableAt };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Claim unbonded stake
|
|
94
|
+
*/
|
|
95
|
+
claim(nodeId, creditSystem) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
const stake = this.stakes.get(nodeId);
|
|
98
|
+
if (!stake) {
|
|
99
|
+
throw new Error(`No stake found for: ${nodeId}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let claimed = 0;
|
|
103
|
+
this.unbondingQueue = this.unbondingQueue.filter(item => {
|
|
104
|
+
if (item.nodeId === nodeId && item.availableAt <= now) {
|
|
105
|
+
claimed += item.amount;
|
|
106
|
+
stake.unbonding -= item.amount;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (claimed > 0) {
|
|
113
|
+
creditSystem.earnCredits(nodeId, claimed, `unstake-claim-${Date.now()}`);
|
|
114
|
+
this.emit('claimed', { nodeId, amount: claimed });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { claimed };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Slash stake for misbehavior
|
|
122
|
+
*/
|
|
123
|
+
slash(nodeId, reason, severity = 1.0) {
|
|
124
|
+
const stake = this.stakes.get(nodeId);
|
|
125
|
+
if (!stake || stake.staked === 0) {
|
|
126
|
+
return { slashed: 0, reason: 'No stake to slash' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Calculate slash amount
|
|
130
|
+
const slashAmount = Math.min(
|
|
131
|
+
stake.staked * this.config.slashRate * severity,
|
|
132
|
+
stake.staked * this.config.maxSlashPerPeriod
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
stake.staked -= slashAmount;
|
|
136
|
+
stake.reputation = Math.max(0, stake.reputation - 10 * severity);
|
|
137
|
+
stake.failedTasks++;
|
|
138
|
+
|
|
139
|
+
// Record slash history
|
|
140
|
+
if (!this.slashHistory.has(nodeId)) {
|
|
141
|
+
this.slashHistory.set(nodeId, []);
|
|
142
|
+
}
|
|
143
|
+
this.slashHistory.get(nodeId).push({
|
|
144
|
+
reason,
|
|
145
|
+
amount: slashAmount,
|
|
146
|
+
severity,
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
this.emit('slashed', { nodeId, amount: slashAmount, reason, newReputation: stake.reputation });
|
|
151
|
+
|
|
152
|
+
return { slashed: slashAmount, newStake: stake.staked, newReputation: stake.reputation };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Record successful task (increases reputation)
|
|
157
|
+
*/
|
|
158
|
+
recordSuccess(nodeId) {
|
|
159
|
+
const stake = this.stakes.get(nodeId);
|
|
160
|
+
if (!stake) return;
|
|
161
|
+
|
|
162
|
+
stake.successfulTasks++;
|
|
163
|
+
stake.reputation = Math.min(100, stake.reputation + 1);
|
|
164
|
+
stake.lastActivity = Date.now();
|
|
165
|
+
|
|
166
|
+
this.emit('success', { nodeId, reputation: stake.reputation });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Record failed task (may trigger slash)
|
|
171
|
+
*/
|
|
172
|
+
recordFailure(nodeId, reason) {
|
|
173
|
+
const stake = this.stakes.get(nodeId);
|
|
174
|
+
if (!stake) return;
|
|
175
|
+
|
|
176
|
+
stake.failedTasks++;
|
|
177
|
+
stake.lastActivity = Date.now();
|
|
178
|
+
|
|
179
|
+
// Calculate failure rate
|
|
180
|
+
const totalTasks = stake.successfulTasks + stake.failedTasks;
|
|
181
|
+
const failureRate = stake.failedTasks / totalTasks;
|
|
182
|
+
|
|
183
|
+
// Slash if failure rate too high
|
|
184
|
+
if (failureRate > 0.3 && totalTasks >= 10) {
|
|
185
|
+
this.slash(nodeId, reason, failureRate);
|
|
186
|
+
} else {
|
|
187
|
+
// Just reduce reputation
|
|
188
|
+
stake.reputation = Math.max(0, stake.reputation - 5);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get stake info
|
|
194
|
+
*/
|
|
195
|
+
getStake(nodeId) {
|
|
196
|
+
return this.stakes.get(nodeId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get reputation score
|
|
201
|
+
*/
|
|
202
|
+
getReputation(nodeId) {
|
|
203
|
+
const stake = this.stakes.get(nodeId);
|
|
204
|
+
return stake?.reputation ?? 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get leaderboard
|
|
209
|
+
*/
|
|
210
|
+
getLeaderboard(limit = 10) {
|
|
211
|
+
return Array.from(this.stakes.entries())
|
|
212
|
+
.map(([nodeId, stake]) => ({
|
|
213
|
+
nodeId,
|
|
214
|
+
staked: stake.staked,
|
|
215
|
+
reputation: stake.reputation,
|
|
216
|
+
successRate: stake.successfulTasks / (stake.successfulTasks + stake.failedTasks || 1),
|
|
217
|
+
}))
|
|
218
|
+
.sort((a, b) => b.reputation - a.reputation || b.staked - a.staked)
|
|
219
|
+
.slice(0, limit);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Check if node is eligible for tasks
|
|
224
|
+
*/
|
|
225
|
+
isEligible(nodeId, minReputation = 50, minStake = 0) {
|
|
226
|
+
const stake = this.stakes.get(nodeId);
|
|
227
|
+
if (!stake) return false;
|
|
228
|
+
|
|
229
|
+
return stake.reputation >= minReputation && stake.staked >= minStake;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getStats() {
|
|
233
|
+
const stakes = Array.from(this.stakes.values());
|
|
234
|
+
return {
|
|
235
|
+
totalStaked: stakes.reduce((sum, s) => sum + s.staked, 0),
|
|
236
|
+
totalUnbonding: stakes.reduce((sum, s) => sum + s.unbonding, 0),
|
|
237
|
+
stakerCount: this.stakes.size,
|
|
238
|
+
averageReputation: stakes.reduce((sum, s) => sum + s.reputation, 0) / (stakes.length || 1),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export default ReputationStakingPlugin;
|