@ruvector/edge-net 0.1.2 → 0.1.4
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/README.md +160 -0
- package/agents.js +965 -0
- package/package.json +27 -3
- package/real-agents.js +706 -0
- package/sync.js +799 -0
package/sync.js
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ruvector/edge-net Hybrid Sync Service
|
|
3
|
+
*
|
|
4
|
+
* Multi-device identity and ledger synchronization using:
|
|
5
|
+
* - P2P sync via WebRTC (fast, direct when devices online together)
|
|
6
|
+
* - Firestore sync (persistent fallback, cross-session)
|
|
7
|
+
* - Identity linking via PiKey signatures
|
|
8
|
+
*
|
|
9
|
+
* @module @ruvector/edge-net/sync
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { EventEmitter } from 'events';
|
|
13
|
+
import { createHash, randomBytes } from 'crypto';
|
|
14
|
+
|
|
15
|
+
// ============================================
|
|
16
|
+
// SYNC CONFIGURATION
|
|
17
|
+
// ============================================
|
|
18
|
+
|
|
19
|
+
export const SYNC_CONFIG = {
|
|
20
|
+
// Firestore endpoints (Genesis nodes)
|
|
21
|
+
firestore: {
|
|
22
|
+
projectId: 'ruvector-edge-net',
|
|
23
|
+
collection: 'ledger-sync',
|
|
24
|
+
identityCollection: 'identity-links',
|
|
25
|
+
},
|
|
26
|
+
// Sync intervals
|
|
27
|
+
intervals: {
|
|
28
|
+
p2pHeartbeat: 5000, // 5s P2P sync check
|
|
29
|
+
firestoreSync: 30000, // 30s Firestore sync
|
|
30
|
+
staleThreshold: 60000, // 1min before considering state stale
|
|
31
|
+
},
|
|
32
|
+
// CRDT merge settings
|
|
33
|
+
crdt: {
|
|
34
|
+
maxBatchSize: 1000, // Max entries per merge
|
|
35
|
+
conflictResolution: 'lww', // Last-write-wins
|
|
36
|
+
},
|
|
37
|
+
// Genesis node endpoints
|
|
38
|
+
genesisNodes: [
|
|
39
|
+
{ region: 'us-central1', url: 'https://edge-net-genesis-us.ruvector.dev' },
|
|
40
|
+
{ region: 'europe-west1', url: 'https://edge-net-genesis-eu.ruvector.dev' },
|
|
41
|
+
{ region: 'asia-east1', url: 'https://edge-net-genesis-asia.ruvector.dev' },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ============================================
|
|
46
|
+
// IDENTITY LINKER
|
|
47
|
+
// ============================================
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Links a PiKey identity across multiple devices
|
|
51
|
+
* Uses cryptographic challenge-response to prove ownership
|
|
52
|
+
*/
|
|
53
|
+
export class IdentityLinker extends EventEmitter {
|
|
54
|
+
constructor(piKey, options = {}) {
|
|
55
|
+
super();
|
|
56
|
+
this.piKey = piKey;
|
|
57
|
+
this.publicKeyHex = this.toHex(piKey.getPublicKey());
|
|
58
|
+
this.shortId = piKey.getShortId();
|
|
59
|
+
this.options = {
|
|
60
|
+
genesisUrl: options.genesisUrl || SYNC_CONFIG.genesisNodes[0].url,
|
|
61
|
+
...options,
|
|
62
|
+
};
|
|
63
|
+
this.linkedDevices = new Map();
|
|
64
|
+
this.authToken = null;
|
|
65
|
+
this.deviceId = this.generateDeviceId();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate unique device ID
|
|
70
|
+
*/
|
|
71
|
+
generateDeviceId() {
|
|
72
|
+
const platform = typeof window !== 'undefined' ? 'browser' : 'node';
|
|
73
|
+
const random = randomBytes(8).toString('hex');
|
|
74
|
+
const timestamp = Date.now().toString(36);
|
|
75
|
+
return `${platform}-${timestamp}-${random}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Authenticate with genesis node using PiKey signature
|
|
80
|
+
*/
|
|
81
|
+
async authenticate() {
|
|
82
|
+
try {
|
|
83
|
+
// Step 1: Request challenge
|
|
84
|
+
const challengeRes = await this.fetchWithTimeout(
|
|
85
|
+
`${this.options.genesisUrl}/api/v1/identity/challenge`,
|
|
86
|
+
{
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
publicKey: this.publicKeyHex,
|
|
91
|
+
deviceId: this.deviceId,
|
|
92
|
+
}),
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!challengeRes.ok) {
|
|
97
|
+
throw new Error(`Challenge request failed: ${challengeRes.status}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { challenge, nonce } = await challengeRes.json();
|
|
101
|
+
|
|
102
|
+
// Step 2: Sign challenge with PiKey
|
|
103
|
+
const challengeBytes = this.fromHex(challenge);
|
|
104
|
+
const signature = this.piKey.sign(challengeBytes);
|
|
105
|
+
|
|
106
|
+
// Step 3: Submit signature for verification
|
|
107
|
+
const authRes = await this.fetchWithTimeout(
|
|
108
|
+
`${this.options.genesisUrl}/api/v1/identity/verify`,
|
|
109
|
+
{
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
publicKey: this.publicKeyHex,
|
|
114
|
+
deviceId: this.deviceId,
|
|
115
|
+
nonce,
|
|
116
|
+
signature: this.toHex(signature),
|
|
117
|
+
}),
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (!authRes.ok) {
|
|
122
|
+
throw new Error(`Authentication failed: ${authRes.status}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const { token, expiresAt, linkedDevices } = await authRes.json();
|
|
126
|
+
|
|
127
|
+
this.authToken = token;
|
|
128
|
+
this.tokenExpiry = new Date(expiresAt);
|
|
129
|
+
|
|
130
|
+
// Update linked devices
|
|
131
|
+
for (const device of linkedDevices || []) {
|
|
132
|
+
this.linkedDevices.set(device.deviceId, device);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.emit('authenticated', {
|
|
136
|
+
deviceId: this.deviceId,
|
|
137
|
+
linkedDevices: this.linkedDevices.size,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return { success: true, token, linkedDevices: this.linkedDevices.size };
|
|
141
|
+
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// Fallback: Generate local-only token for P2P sync
|
|
144
|
+
console.warn('[Sync] Genesis authentication failed, using local mode:', error.message);
|
|
145
|
+
this.authToken = this.generateLocalToken();
|
|
146
|
+
this.emit('authenticated', { deviceId: this.deviceId, mode: 'local' });
|
|
147
|
+
return { success: true, mode: 'local' };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Generate local token for P2P-only mode
|
|
153
|
+
*/
|
|
154
|
+
generateLocalToken() {
|
|
155
|
+
const payload = {
|
|
156
|
+
sub: this.publicKeyHex,
|
|
157
|
+
dev: this.deviceId,
|
|
158
|
+
iat: Date.now(),
|
|
159
|
+
mode: 'local',
|
|
160
|
+
};
|
|
161
|
+
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Link a new device to this identity
|
|
166
|
+
*/
|
|
167
|
+
async linkDevice(deviceInfo) {
|
|
168
|
+
if (!this.authToken) {
|
|
169
|
+
await this.authenticate();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const res = await this.fetchWithTimeout(
|
|
174
|
+
`${this.options.genesisUrl}/api/v1/identity/link`,
|
|
175
|
+
{
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: {
|
|
178
|
+
'Content-Type': 'application/json',
|
|
179
|
+
'Authorization': `Bearer ${this.authToken}`,
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
publicKey: this.publicKeyHex,
|
|
183
|
+
newDevice: deviceInfo,
|
|
184
|
+
}),
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
throw new Error(`Link failed: ${res.status}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const result = await res.json();
|
|
193
|
+
this.linkedDevices.set(deviceInfo.deviceId, deviceInfo);
|
|
194
|
+
|
|
195
|
+
this.emit('device_linked', { deviceId: deviceInfo.deviceId });
|
|
196
|
+
return result;
|
|
197
|
+
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// P2P fallback: Store in local linked devices for gossip
|
|
200
|
+
this.linkedDevices.set(deviceInfo.deviceId, {
|
|
201
|
+
...deviceInfo,
|
|
202
|
+
linkedAt: Date.now(),
|
|
203
|
+
mode: 'p2p',
|
|
204
|
+
});
|
|
205
|
+
return { success: true, mode: 'p2p' };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get all linked devices
|
|
211
|
+
*/
|
|
212
|
+
getLinkedDevices() {
|
|
213
|
+
return Array.from(this.linkedDevices.values());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check if a device is linked to this identity
|
|
218
|
+
*/
|
|
219
|
+
isDeviceLinked(deviceId) {
|
|
220
|
+
return this.linkedDevices.has(deviceId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Utility methods
|
|
224
|
+
toHex(bytes) {
|
|
225
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fromHex(hex) {
|
|
229
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
230
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
231
|
+
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
|
232
|
+
}
|
|
233
|
+
return bytes;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async fetchWithTimeout(url, options, timeout = 10000) {
|
|
237
|
+
const controller = new AbortController();
|
|
238
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
239
|
+
try {
|
|
240
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
241
|
+
clearTimeout(id);
|
|
242
|
+
return response;
|
|
243
|
+
} catch (error) {
|
|
244
|
+
clearTimeout(id);
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ============================================
|
|
251
|
+
// LEDGER SYNC SERVICE
|
|
252
|
+
// ============================================
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Hybrid sync service for credit ledger
|
|
256
|
+
* Combines P2P (fast) and Firestore (persistent) sync
|
|
257
|
+
*/
|
|
258
|
+
export class LedgerSyncService extends EventEmitter {
|
|
259
|
+
constructor(identityLinker, ledger, options = {}) {
|
|
260
|
+
super();
|
|
261
|
+
this.identity = identityLinker;
|
|
262
|
+
this.ledger = ledger;
|
|
263
|
+
this.options = {
|
|
264
|
+
enableP2P: true,
|
|
265
|
+
enableFirestore: true,
|
|
266
|
+
syncInterval: SYNC_CONFIG.intervals.firestoreSync,
|
|
267
|
+
...options,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// Sync state
|
|
271
|
+
this.lastSyncTime = 0;
|
|
272
|
+
this.syncInProgress = false;
|
|
273
|
+
this.pendingChanges = [];
|
|
274
|
+
this.peerStates = new Map(); // deviceId -> { earned, spent, timestamp }
|
|
275
|
+
this.vectorClock = new Map(); // deviceId -> counter
|
|
276
|
+
|
|
277
|
+
// P2P connections
|
|
278
|
+
this.p2pPeers = new Map();
|
|
279
|
+
|
|
280
|
+
// Intervals
|
|
281
|
+
this.syncIntervalId = null;
|
|
282
|
+
this.heartbeatId = null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Start sync service
|
|
287
|
+
*/
|
|
288
|
+
async start() {
|
|
289
|
+
// Authenticate first
|
|
290
|
+
await this.identity.authenticate();
|
|
291
|
+
|
|
292
|
+
// Start periodic sync
|
|
293
|
+
if (this.options.enableFirestore) {
|
|
294
|
+
this.syncIntervalId = setInterval(
|
|
295
|
+
() => this.syncWithFirestore(),
|
|
296
|
+
this.options.syncInterval
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Start P2P heartbeat
|
|
301
|
+
if (this.options.enableP2P) {
|
|
302
|
+
this.heartbeatId = setInterval(
|
|
303
|
+
() => this.p2pHeartbeat(),
|
|
304
|
+
SYNC_CONFIG.intervals.p2pHeartbeat
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Initial sync
|
|
309
|
+
await this.fullSync();
|
|
310
|
+
|
|
311
|
+
this.emit('started', { deviceId: this.identity.deviceId });
|
|
312
|
+
return this;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Stop sync service
|
|
317
|
+
*/
|
|
318
|
+
stop() {
|
|
319
|
+
if (this.syncIntervalId) {
|
|
320
|
+
clearInterval(this.syncIntervalId);
|
|
321
|
+
this.syncIntervalId = null;
|
|
322
|
+
}
|
|
323
|
+
if (this.heartbeatId) {
|
|
324
|
+
clearInterval(this.heartbeatId);
|
|
325
|
+
this.heartbeatId = null;
|
|
326
|
+
}
|
|
327
|
+
this.emit('stopped');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Full sync - fetch from all sources and merge
|
|
332
|
+
*/
|
|
333
|
+
async fullSync() {
|
|
334
|
+
if (this.syncInProgress) return;
|
|
335
|
+
this.syncInProgress = true;
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const results = await Promise.allSettled([
|
|
339
|
+
this.options.enableFirestore ? this.fetchFromFirestore() : null,
|
|
340
|
+
this.options.enableP2P ? this.fetchFromP2PPeers() : null,
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
// Merge all fetched states
|
|
344
|
+
for (const result of results) {
|
|
345
|
+
if (result.status === 'fulfilled' && result.value) {
|
|
346
|
+
await this.mergeState(result.value);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Push our state
|
|
351
|
+
await this.pushState();
|
|
352
|
+
|
|
353
|
+
this.lastSyncTime = Date.now();
|
|
354
|
+
this.emit('synced', {
|
|
355
|
+
timestamp: this.lastSyncTime,
|
|
356
|
+
balance: this.ledger.balance(),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
} catch (error) {
|
|
360
|
+
this.emit('sync_error', { error: error.message });
|
|
361
|
+
} finally {
|
|
362
|
+
this.syncInProgress = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Fetch ledger state from Firestore
|
|
368
|
+
*/
|
|
369
|
+
async fetchFromFirestore() {
|
|
370
|
+
if (!this.identity.authToken) return null;
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const res = await this.identity.fetchWithTimeout(
|
|
374
|
+
`${this.identity.options.genesisUrl}/api/v1/ledger/${this.identity.publicKeyHex}`,
|
|
375
|
+
{
|
|
376
|
+
method: 'GET',
|
|
377
|
+
headers: {
|
|
378
|
+
'Authorization': `Bearer ${this.identity.authToken}`,
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
if (res.status === 404) return null; // No state yet
|
|
385
|
+
throw new Error(`Firestore fetch failed: ${res.status}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const { states } = await res.json();
|
|
389
|
+
return states; // Array of { deviceId, earned, spent, timestamp }
|
|
390
|
+
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.warn('[Sync] Firestore fetch failed:', error.message);
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Fetch ledger state from P2P peers
|
|
399
|
+
*/
|
|
400
|
+
async fetchFromP2PPeers() {
|
|
401
|
+
const states = [];
|
|
402
|
+
|
|
403
|
+
for (const [peerId, peer] of this.p2pPeers) {
|
|
404
|
+
try {
|
|
405
|
+
if (peer.dataChannel?.readyState === 'open') {
|
|
406
|
+
const state = await this.requestStateFromPeer(peer);
|
|
407
|
+
if (state) {
|
|
408
|
+
states.push({ deviceId: peerId, ...state });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
console.warn(`[Sync] P2P fetch from ${peerId} failed:`, error.message);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return states.length > 0 ? states : null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Request state from a P2P peer
|
|
421
|
+
*/
|
|
422
|
+
requestStateFromPeer(peer) {
|
|
423
|
+
return new Promise((resolve, reject) => {
|
|
424
|
+
const requestId = randomBytes(8).toString('hex');
|
|
425
|
+
const timeout = setTimeout(() => {
|
|
426
|
+
reject(new Error('P2P state request timeout'));
|
|
427
|
+
}, 5000);
|
|
428
|
+
|
|
429
|
+
const handler = (event) => {
|
|
430
|
+
try {
|
|
431
|
+
const msg = JSON.parse(event.data);
|
|
432
|
+
if (msg.type === 'ledger_state' && msg.requestId === requestId) {
|
|
433
|
+
clearTimeout(timeout);
|
|
434
|
+
peer.dataChannel.removeEventListener('message', handler);
|
|
435
|
+
resolve(msg.state);
|
|
436
|
+
}
|
|
437
|
+
} catch (e) { /* ignore */ }
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
peer.dataChannel.addEventListener('message', handler);
|
|
441
|
+
peer.dataChannel.send(JSON.stringify({
|
|
442
|
+
type: 'ledger_state_request',
|
|
443
|
+
requestId,
|
|
444
|
+
from: this.identity.deviceId,
|
|
445
|
+
}));
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Merge remote state into local ledger (CRDT)
|
|
451
|
+
*/
|
|
452
|
+
async mergeState(states) {
|
|
453
|
+
if (!states || !Array.isArray(states)) return;
|
|
454
|
+
|
|
455
|
+
for (const state of states) {
|
|
456
|
+
// Skip our own state
|
|
457
|
+
if (state.deviceId === this.identity.deviceId) continue;
|
|
458
|
+
|
|
459
|
+
// Check vector clock for freshness
|
|
460
|
+
const lastSeen = this.vectorClock.get(state.deviceId) || 0;
|
|
461
|
+
if (state.timestamp <= lastSeen) continue;
|
|
462
|
+
|
|
463
|
+
// CRDT merge
|
|
464
|
+
try {
|
|
465
|
+
if (state.earned && state.spent) {
|
|
466
|
+
const earned = typeof state.earned === 'string'
|
|
467
|
+
? JSON.parse(state.earned)
|
|
468
|
+
: state.earned;
|
|
469
|
+
const spent = typeof state.spent === 'string'
|
|
470
|
+
? JSON.parse(state.spent)
|
|
471
|
+
: state.spent;
|
|
472
|
+
|
|
473
|
+
this.ledger.merge(
|
|
474
|
+
JSON.stringify(earned),
|
|
475
|
+
JSON.stringify(spent)
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Update vector clock
|
|
480
|
+
this.vectorClock.set(state.deviceId, state.timestamp);
|
|
481
|
+
this.peerStates.set(state.deviceId, state);
|
|
482
|
+
|
|
483
|
+
this.emit('state_merged', {
|
|
484
|
+
deviceId: state.deviceId,
|
|
485
|
+
newBalance: this.ledger.balance(),
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
} catch (error) {
|
|
489
|
+
console.warn(`[Sync] Merge failed for ${state.deviceId}:`, error.message);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Push local state to sync destinations
|
|
496
|
+
*/
|
|
497
|
+
async pushState() {
|
|
498
|
+
const state = this.exportState();
|
|
499
|
+
|
|
500
|
+
// Push to Firestore
|
|
501
|
+
if (this.options.enableFirestore && this.identity.authToken) {
|
|
502
|
+
await this.pushToFirestore(state);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Broadcast to P2P peers
|
|
506
|
+
if (this.options.enableP2P) {
|
|
507
|
+
this.broadcastToP2P(state);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Export current ledger state
|
|
513
|
+
*/
|
|
514
|
+
exportState() {
|
|
515
|
+
return {
|
|
516
|
+
deviceId: this.identity.deviceId,
|
|
517
|
+
publicKey: this.identity.publicKeyHex,
|
|
518
|
+
earned: this.ledger.exportEarned(),
|
|
519
|
+
spent: this.ledger.exportSpent(),
|
|
520
|
+
balance: this.ledger.balance(),
|
|
521
|
+
totalEarned: this.ledger.totalEarned(),
|
|
522
|
+
totalSpent: this.ledger.totalSpent(),
|
|
523
|
+
timestamp: Date.now(),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Push state to Firestore
|
|
529
|
+
*/
|
|
530
|
+
async pushToFirestore(state) {
|
|
531
|
+
try {
|
|
532
|
+
const res = await this.identity.fetchWithTimeout(
|
|
533
|
+
`${this.identity.options.genesisUrl}/api/v1/ledger/${this.identity.publicKeyHex}`,
|
|
534
|
+
{
|
|
535
|
+
method: 'PUT',
|
|
536
|
+
headers: {
|
|
537
|
+
'Content-Type': 'application/json',
|
|
538
|
+
'Authorization': `Bearer ${this.identity.authToken}`,
|
|
539
|
+
},
|
|
540
|
+
body: JSON.stringify({
|
|
541
|
+
deviceId: state.deviceId,
|
|
542
|
+
earned: state.earned,
|
|
543
|
+
spent: state.spent,
|
|
544
|
+
timestamp: state.timestamp,
|
|
545
|
+
}),
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
if (!res.ok) {
|
|
550
|
+
throw new Error(`Firestore push failed: ${res.status}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return true;
|
|
554
|
+
|
|
555
|
+
} catch (error) {
|
|
556
|
+
console.warn('[Sync] Firestore push failed:', error.message);
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Broadcast state to P2P peers
|
|
563
|
+
*/
|
|
564
|
+
broadcastToP2P(state) {
|
|
565
|
+
const message = JSON.stringify({
|
|
566
|
+
type: 'ledger_state_broadcast',
|
|
567
|
+
state: {
|
|
568
|
+
deviceId: state.deviceId,
|
|
569
|
+
earned: state.earned,
|
|
570
|
+
spent: state.spent,
|
|
571
|
+
timestamp: state.timestamp,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
for (const [peerId, peer] of this.p2pPeers) {
|
|
576
|
+
try {
|
|
577
|
+
if (peer.dataChannel?.readyState === 'open') {
|
|
578
|
+
peer.dataChannel.send(message);
|
|
579
|
+
}
|
|
580
|
+
} catch (error) {
|
|
581
|
+
console.warn(`[Sync] P2P broadcast to ${peerId} failed:`, error.message);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* P2P heartbeat - discover and sync with nearby devices
|
|
588
|
+
*/
|
|
589
|
+
async p2pHeartbeat() {
|
|
590
|
+
// Broadcast presence to linked devices
|
|
591
|
+
const presence = {
|
|
592
|
+
type: 'presence',
|
|
593
|
+
deviceId: this.identity.deviceId,
|
|
594
|
+
publicKey: this.identity.publicKeyHex,
|
|
595
|
+
balance: this.ledger.balance(),
|
|
596
|
+
timestamp: Date.now(),
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
for (const [peerId, peer] of this.p2pPeers) {
|
|
600
|
+
try {
|
|
601
|
+
if (peer.dataChannel?.readyState === 'open') {
|
|
602
|
+
peer.dataChannel.send(JSON.stringify(presence));
|
|
603
|
+
}
|
|
604
|
+
} catch (error) {
|
|
605
|
+
// Remove stale peer
|
|
606
|
+
this.p2pPeers.delete(peerId);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Register a P2P peer for sync
|
|
613
|
+
*/
|
|
614
|
+
registerP2PPeer(peerId, dataChannel) {
|
|
615
|
+
this.p2pPeers.set(peerId, { dataChannel, connectedAt: Date.now() });
|
|
616
|
+
|
|
617
|
+
// Handle incoming messages
|
|
618
|
+
dataChannel.addEventListener('message', (event) => {
|
|
619
|
+
this.handleP2PMessage(peerId, event.data);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
this.emit('peer_registered', { peerId });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Handle incoming P2P message
|
|
627
|
+
*/
|
|
628
|
+
async handleP2PMessage(peerId, data) {
|
|
629
|
+
try {
|
|
630
|
+
const msg = JSON.parse(data);
|
|
631
|
+
|
|
632
|
+
switch (msg.type) {
|
|
633
|
+
case 'ledger_state_request':
|
|
634
|
+
// Respond with our state
|
|
635
|
+
const state = this.exportState();
|
|
636
|
+
const peer = this.p2pPeers.get(peerId);
|
|
637
|
+
if (peer?.dataChannel?.readyState === 'open') {
|
|
638
|
+
peer.dataChannel.send(JSON.stringify({
|
|
639
|
+
type: 'ledger_state',
|
|
640
|
+
requestId: msg.requestId,
|
|
641
|
+
state: {
|
|
642
|
+
earned: state.earned,
|
|
643
|
+
spent: state.spent,
|
|
644
|
+
timestamp: state.timestamp,
|
|
645
|
+
},
|
|
646
|
+
}));
|
|
647
|
+
}
|
|
648
|
+
break;
|
|
649
|
+
|
|
650
|
+
case 'ledger_state_broadcast':
|
|
651
|
+
// Merge incoming state
|
|
652
|
+
if (msg.state) {
|
|
653
|
+
await this.mergeState([{ deviceId: peerId, ...msg.state }]);
|
|
654
|
+
}
|
|
655
|
+
break;
|
|
656
|
+
|
|
657
|
+
case 'presence':
|
|
658
|
+
// Update peer info
|
|
659
|
+
const existingPeer = this.p2pPeers.get(peerId);
|
|
660
|
+
if (existingPeer) {
|
|
661
|
+
existingPeer.lastSeen = Date.now();
|
|
662
|
+
existingPeer.balance = msg.balance;
|
|
663
|
+
}
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
} catch (error) {
|
|
668
|
+
console.warn(`[Sync] P2P message handling failed:`, error.message);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Sync with Firestore (called periodically)
|
|
674
|
+
*/
|
|
675
|
+
async syncWithFirestore() {
|
|
676
|
+
if (this.syncInProgress) return;
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
const states = await this.fetchFromFirestore();
|
|
680
|
+
if (states) {
|
|
681
|
+
await this.mergeState(states);
|
|
682
|
+
}
|
|
683
|
+
await this.pushToFirestore(this.exportState());
|
|
684
|
+
} catch (error) {
|
|
685
|
+
console.warn('[Sync] Periodic Firestore sync failed:', error.message);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Force immediate sync
|
|
691
|
+
*/
|
|
692
|
+
async forceSync() {
|
|
693
|
+
return this.fullSync();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Get sync status
|
|
698
|
+
*/
|
|
699
|
+
getStatus() {
|
|
700
|
+
return {
|
|
701
|
+
deviceId: this.identity.deviceId,
|
|
702
|
+
publicKey: this.identity.publicKeyHex,
|
|
703
|
+
shortId: this.identity.shortId,
|
|
704
|
+
linkedDevices: this.identity.getLinkedDevices().length,
|
|
705
|
+
p2pPeers: this.p2pPeers.size,
|
|
706
|
+
lastSyncTime: this.lastSyncTime,
|
|
707
|
+
balance: this.ledger.balance(),
|
|
708
|
+
totalEarned: this.ledger.totalEarned(),
|
|
709
|
+
totalSpent: this.ledger.totalSpent(),
|
|
710
|
+
syncEnabled: {
|
|
711
|
+
p2p: this.options.enableP2P,
|
|
712
|
+
firestore: this.options.enableFirestore,
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ============================================
|
|
719
|
+
// SYNC MANAGER (CONVENIENCE WRAPPER)
|
|
720
|
+
// ============================================
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* High-level sync manager for easy integration
|
|
724
|
+
*/
|
|
725
|
+
export class SyncManager extends EventEmitter {
|
|
726
|
+
constructor(piKey, ledger, options = {}) {
|
|
727
|
+
super();
|
|
728
|
+
this.identityLinker = new IdentityLinker(piKey, options);
|
|
729
|
+
this.syncService = new LedgerSyncService(this.identityLinker, ledger, options);
|
|
730
|
+
|
|
731
|
+
// Forward events
|
|
732
|
+
this.syncService.on('synced', (data) => this.emit('synced', data));
|
|
733
|
+
this.syncService.on('state_merged', (data) => this.emit('state_merged', data));
|
|
734
|
+
this.syncService.on('sync_error', (data) => this.emit('sync_error', data));
|
|
735
|
+
this.identityLinker.on('authenticated', (data) => this.emit('authenticated', data));
|
|
736
|
+
this.identityLinker.on('device_linked', (data) => this.emit('device_linked', data));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Start sync
|
|
741
|
+
*/
|
|
742
|
+
async start() {
|
|
743
|
+
await this.syncService.start();
|
|
744
|
+
return this;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Stop sync
|
|
749
|
+
*/
|
|
750
|
+
stop() {
|
|
751
|
+
this.syncService.stop();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Force sync
|
|
756
|
+
*/
|
|
757
|
+
async sync() {
|
|
758
|
+
return this.syncService.forceSync();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Register P2P peer
|
|
763
|
+
*/
|
|
764
|
+
registerPeer(peerId, dataChannel) {
|
|
765
|
+
this.syncService.registerP2PPeer(peerId, dataChannel);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Get status
|
|
770
|
+
*/
|
|
771
|
+
getStatus() {
|
|
772
|
+
return this.syncService.getStatus();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Export identity for another device
|
|
777
|
+
*/
|
|
778
|
+
exportIdentity(password) {
|
|
779
|
+
return this.identityLinker.piKey.createEncryptedBackup(password);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Link devices via QR code data
|
|
784
|
+
*/
|
|
785
|
+
generateLinkData() {
|
|
786
|
+
return {
|
|
787
|
+
publicKey: this.identityLinker.publicKeyHex,
|
|
788
|
+
shortId: this.identityLinker.shortId,
|
|
789
|
+
genesisUrl: this.identityLinker.options.genesisUrl,
|
|
790
|
+
timestamp: Date.now(),
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ============================================
|
|
796
|
+
// EXPORTS
|
|
797
|
+
// ============================================
|
|
798
|
+
|
|
799
|
+
export default SyncManager;
|