@gethashd/bytecave-browser 1.0.1
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/AGENTS.md +176 -0
- package/README.md +699 -0
- package/dist/__tests__/p2p-protocols.test.d.ts +10 -0
- package/dist/chunk-EEZWRIUI.js +160 -0
- package/dist/chunk-OJEETLZQ.js +7087 -0
- package/dist/client.d.ts +107 -0
- package/dist/contracts/ContentRegistry.d.ts +1 -0
- package/dist/discovery.d.ts +28 -0
- package/dist/index.cjs +7291 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +50 -0
- package/dist/p2p-protocols.d.ts +114 -0
- package/dist/protocol-handler.cjs +185 -0
- package/dist/protocol-handler.d.ts +57 -0
- package/dist/protocol-handler.js +18 -0
- package/dist/provider.d.ts +59 -0
- package/dist/react/components.d.ts +90 -0
- package/dist/react/hooks.d.ts +67 -0
- package/dist/react/index.cjs +6344 -0
- package/dist/react/index.d.ts +8 -0
- package/dist/react/index.js +23 -0
- package/dist/react/useHashdUrl.d.ts +15 -0
- package/dist/types.d.ts +53 -0
- package/package.json +77 -0
- package/src/__tests__/p2p-protocols.test.ts +292 -0
- package/src/client.ts +876 -0
- package/src/contracts/ContentRegistry.ts +6 -0
- package/src/discovery.ts +79 -0
- package/src/index.ts +59 -0
- package/src/p2p-protocols.ts +451 -0
- package/src/protocol-handler.ts +271 -0
- package/src/provider.tsx +275 -0
- package/src/react/components.tsx +177 -0
- package/src/react/hooks.ts +253 -0
- package/src/react/index.ts +9 -0
- package/src/react/useHashdUrl.ts +68 -0
- package/src/types.ts +60 -0
- package/tsconfig.json +19 -0
- package/tsup.config.ts +17 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ByteCave Browser Client
|
|
3
|
+
*
|
|
4
|
+
* WebRTC P2P client for connecting browsers directly to ByteCave storage nodes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createLibp2p, Libp2p } from 'libp2p';
|
|
8
|
+
import { webRTC } from '@libp2p/webrtc';
|
|
9
|
+
import { webSockets } from '@libp2p/websockets';
|
|
10
|
+
import { noise } from '@chainsafe/libp2p-noise';
|
|
11
|
+
import { yamux } from '@chainsafe/libp2p-yamux';
|
|
12
|
+
import { floodsub } from '@libp2p/floodsub';
|
|
13
|
+
import { identify } from '@libp2p/identify';
|
|
14
|
+
import { bootstrap } from '@libp2p/bootstrap';
|
|
15
|
+
import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
|
|
16
|
+
import { multiaddr } from '@multiformats/multiaddr';
|
|
17
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
18
|
+
import { fromString, toString } from 'uint8arrays';
|
|
19
|
+
import { ethers } from 'ethers';
|
|
20
|
+
import { ContractDiscovery } from './discovery.js';
|
|
21
|
+
import { p2pProtocolClient } from './p2p-protocols.js';
|
|
22
|
+
import { CONTENT_REGISTRY_ABI } from './contracts/ContentRegistry.js';
|
|
23
|
+
import type {
|
|
24
|
+
ByteCaveConfig,
|
|
25
|
+
PeerInfo,
|
|
26
|
+
StoreResult,
|
|
27
|
+
RetrieveResult,
|
|
28
|
+
ConnectionState,
|
|
29
|
+
SignalingMessage
|
|
30
|
+
} from './types.js';
|
|
31
|
+
|
|
32
|
+
const ANNOUNCE_TOPIC = 'bytecave-announce';
|
|
33
|
+
const SIGNALING_TOPIC_PREFIX = 'bytecave-signaling-';
|
|
34
|
+
|
|
35
|
+
export class ByteCaveClient {
|
|
36
|
+
private node: Libp2p | null = null;
|
|
37
|
+
private discovery?: ContractDiscovery; // Optional - only if contract address provided
|
|
38
|
+
private config: ByteCaveConfig;
|
|
39
|
+
private knownPeers: Map<string, PeerInfo> = new Map();
|
|
40
|
+
private connectionState: ConnectionState = 'disconnected';
|
|
41
|
+
private eventListeners: Map<string, Set<Function>> = new Map();
|
|
42
|
+
|
|
43
|
+
constructor(config: ByteCaveConfig) {
|
|
44
|
+
this.config = {
|
|
45
|
+
maxPeers: 10,
|
|
46
|
+
connectionTimeout: 30000,
|
|
47
|
+
...config
|
|
48
|
+
};
|
|
49
|
+
// Only initialize contract discovery if contract address is provided
|
|
50
|
+
if (config.vaultNodeRegistryAddress && config.rpcUrl) {
|
|
51
|
+
this.discovery = new ContractDiscovery(config.vaultNodeRegistryAddress, config.rpcUrl);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Initialize and start the P2P client
|
|
57
|
+
*/
|
|
58
|
+
async start(): Promise<void> {
|
|
59
|
+
if (this.node) {
|
|
60
|
+
console.warn('ByteCave client already started');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.setConnectionState('connecting');
|
|
65
|
+
console.log('[ByteCave] Starting P2P client...');
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const bootstrapPeers: string[] = [];
|
|
69
|
+
|
|
70
|
+
// Add direct node addresses if provided (for direct WebRTC connections)
|
|
71
|
+
if (this.config.directNodeAddrs && this.config.directNodeAddrs.length > 0) {
|
|
72
|
+
console.log('[ByteCave] Using direct node addresses:', this.config.directNodeAddrs);
|
|
73
|
+
bootstrapPeers.push(...this.config.directNodeAddrs);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Use relay peers for fallback (circuit relay connections)
|
|
77
|
+
if (this.config.relayPeers && this.config.relayPeers.length > 0) {
|
|
78
|
+
console.log('[ByteCave] Using relay peers as fallback:', this.config.relayPeers);
|
|
79
|
+
bootstrapPeers.push(...this.config.relayPeers);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (bootstrapPeers.length === 0) {
|
|
83
|
+
console.warn('[ByteCave] No peers configured - will rely on contract discovery only');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log('[ByteCave] Bootstrap peers:', bootstrapPeers);
|
|
87
|
+
|
|
88
|
+
// Create libp2p node with WebRTC transport
|
|
89
|
+
this.node = await createLibp2p({
|
|
90
|
+
transports: [
|
|
91
|
+
webRTC() as any,
|
|
92
|
+
webSockets() as any,
|
|
93
|
+
circuitRelayTransport() as any
|
|
94
|
+
],
|
|
95
|
+
connectionEncrypters: [noise()],
|
|
96
|
+
streamMuxers: [yamux()],
|
|
97
|
+
connectionGater: {
|
|
98
|
+
denyDialMultiaddr: () => false,
|
|
99
|
+
denyDialPeer: () => false,
|
|
100
|
+
denyInboundConnection: () => false,
|
|
101
|
+
denyOutboundConnection: () => false,
|
|
102
|
+
denyInboundEncryptedConnection: () => false,
|
|
103
|
+
denyOutboundEncryptedConnection: () => false,
|
|
104
|
+
denyInboundUpgradedConnection: () => false,
|
|
105
|
+
denyOutboundUpgradedConnection: () => false,
|
|
106
|
+
filterMultiaddrForPeer: () => true
|
|
107
|
+
},
|
|
108
|
+
services: {
|
|
109
|
+
identify: identify(),
|
|
110
|
+
pubsub: floodsub()
|
|
111
|
+
},
|
|
112
|
+
peerDiscovery: bootstrapPeers.length > 0 ? [
|
|
113
|
+
bootstrap({ list: bootstrapPeers })
|
|
114
|
+
] : undefined
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Set up event listeners
|
|
118
|
+
this.setupEventListeners();
|
|
119
|
+
|
|
120
|
+
// Set up pubsub
|
|
121
|
+
await this.setupPubsub();
|
|
122
|
+
|
|
123
|
+
// Start the node
|
|
124
|
+
await this.node.start();
|
|
125
|
+
console.log('[ByteCave] Node started with peerId:', this.node.peerId.toString());
|
|
126
|
+
|
|
127
|
+
// Dial relay peers to bootstrap P2P discovery
|
|
128
|
+
console.log('[ByteCave] Attempting to dial relay peers:', bootstrapPeers);
|
|
129
|
+
|
|
130
|
+
for (const addr of bootstrapPeers) {
|
|
131
|
+
try {
|
|
132
|
+
console.log('[ByteCave] Dialing relay:', addr);
|
|
133
|
+
const ma = multiaddr(addr);
|
|
134
|
+
const connection = await this.node.dial(ma as any);
|
|
135
|
+
console.log('[ByteCave] ✓ Connected to relay:', addr, 'remotePeer:', connection.remotePeer.toString());
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
console.error('[ByteCave] ✗ Failed to dial relay:', addr, 'Error:', err.message || err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const connectedPeers = this.node.getPeers();
|
|
142
|
+
console.log('[ByteCave] Connected peers after relay dial:', connectedPeers.length, connectedPeers.map(p => p.toString()));
|
|
143
|
+
|
|
144
|
+
// Initialize P2P protocol client with the libp2p node
|
|
145
|
+
p2pProtocolClient.setNode(this.node);
|
|
146
|
+
|
|
147
|
+
// Fast discovery: Query relay for peer directory
|
|
148
|
+
console.log('[ByteCave] Querying relay for peer directory...');
|
|
149
|
+
for (const relayAddr of bootstrapPeers) {
|
|
150
|
+
try {
|
|
151
|
+
// Extract relay peer ID from multiaddr
|
|
152
|
+
const parts = relayAddr.split('/p2p/');
|
|
153
|
+
if (parts.length < 2) continue;
|
|
154
|
+
const relayPeerId = parts[parts.length - 1];
|
|
155
|
+
|
|
156
|
+
const directory = await p2pProtocolClient.getPeerDirectoryFromRelay(relayPeerId);
|
|
157
|
+
if (directory && directory.peers.length > 0) {
|
|
158
|
+
console.log('[ByteCave] Got', directory.peers.length, 'peers from relay directory');
|
|
159
|
+
|
|
160
|
+
// Dial each peer and fetch health data
|
|
161
|
+
for (const peer of directory.peers) {
|
|
162
|
+
try {
|
|
163
|
+
console.log('[ByteCave] Dialing peer from directory:', peer.peerId.slice(0, 12) + '...');
|
|
164
|
+
|
|
165
|
+
// Try to dial using circuit relay multiaddr
|
|
166
|
+
let connected = false;
|
|
167
|
+
for (const addr of peer.multiaddrs) {
|
|
168
|
+
try {
|
|
169
|
+
console.log('[ByteCave] Trying multiaddr:', addr);
|
|
170
|
+
const ma = multiaddr(addr);
|
|
171
|
+
await this.node.dial(ma as any);
|
|
172
|
+
connected = true;
|
|
173
|
+
console.log('[ByteCave] ✓ Connected via:', addr);
|
|
174
|
+
break;
|
|
175
|
+
} catch (dialErr: any) {
|
|
176
|
+
console.warn('[ByteCave] Failed to dial via', addr.slice(0, 50) + '...', dialErr.message);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!connected) {
|
|
181
|
+
console.warn('[ByteCave] Could not connect to peer via any multiaddr');
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Fetch health data
|
|
186
|
+
const health = await p2pProtocolClient.getHealthFromPeer(peer.peerId);
|
|
187
|
+
if (health) {
|
|
188
|
+
this.knownPeers.set(peer.peerId, {
|
|
189
|
+
peerId: peer.peerId,
|
|
190
|
+
publicKey: health.publicKey || '',
|
|
191
|
+
contentTypes: health.contentTypes || 'all',
|
|
192
|
+
connected: true,
|
|
193
|
+
nodeId: health.nodeId
|
|
194
|
+
});
|
|
195
|
+
console.log('[ByteCave] ✓ Discovered peer:', health.nodeId || peer.peerId.slice(0, 12));
|
|
196
|
+
}
|
|
197
|
+
} catch (err: any) {
|
|
198
|
+
console.warn('[ByteCave] Failed to process peer from directory:', peer.peerId.slice(0, 12), err.message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
break; // Successfully got directory from one relay
|
|
203
|
+
}
|
|
204
|
+
} catch (err: any) {
|
|
205
|
+
console.warn('[ByteCave] Failed to get directory from relay:', err.message);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.setConnectionState('connected');
|
|
210
|
+
console.log('[ByteCave] Client started', {
|
|
211
|
+
peerId: this.node.peerId.toString(),
|
|
212
|
+
relayPeers: bootstrapPeers.length,
|
|
213
|
+
connectedPeers: connectedPeers.length,
|
|
214
|
+
discoveredPeers: this.knownPeers.size
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.setConnectionState('error');
|
|
219
|
+
console.error('Failed to start ByteCave client:', error);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Stop the P2P client
|
|
226
|
+
*/
|
|
227
|
+
async stop(): Promise<void> {
|
|
228
|
+
if (!this.node) return;
|
|
229
|
+
|
|
230
|
+
await this.node.stop();
|
|
231
|
+
this.node = null;
|
|
232
|
+
this.knownPeers.clear();
|
|
233
|
+
this.setConnectionState('disconnected');
|
|
234
|
+
console.log('ByteCave client stopped');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Refresh peer directory from relay
|
|
239
|
+
* This rediscovers nodes that may have reconnected or restarted
|
|
240
|
+
*/
|
|
241
|
+
async refreshPeerDirectory(): Promise<void> {
|
|
242
|
+
if (!this.node) {
|
|
243
|
+
console.warn('[ByteCave] Cannot refresh - node not initialized');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const bootstrapPeers = [
|
|
248
|
+
...(this.config.directNodeAddrs || []),
|
|
249
|
+
...(this.config.relayPeers || [])
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
console.log('[ByteCave] Refreshing peer directory from relays...');
|
|
253
|
+
|
|
254
|
+
for (const relayAddr of bootstrapPeers) {
|
|
255
|
+
try {
|
|
256
|
+
// Extract relay peer ID from multiaddr
|
|
257
|
+
const parts = relayAddr.split('/p2p/');
|
|
258
|
+
if (parts.length < 2) continue;
|
|
259
|
+
const relayPeerId = parts[parts.length - 1];
|
|
260
|
+
|
|
261
|
+
const directory = await p2pProtocolClient.getPeerDirectoryFromRelay(relayPeerId);
|
|
262
|
+
if (directory && directory.peers.length > 0) {
|
|
263
|
+
console.log('[ByteCave] Refresh: Got', directory.peers.length, 'peers from relay directory');
|
|
264
|
+
|
|
265
|
+
// Check each peer and reconnect if disconnected
|
|
266
|
+
for (const peer of directory.peers) {
|
|
267
|
+
const isConnected = this.node.getPeers().some(p => p.toString() === peer.peerId);
|
|
268
|
+
const knownPeer = this.knownPeers.get(peer.peerId);
|
|
269
|
+
|
|
270
|
+
if (!isConnected || !knownPeer) {
|
|
271
|
+
console.log('[ByteCave] Refresh: Reconnecting to peer:', peer.peerId.slice(0, 12) + '...');
|
|
272
|
+
|
|
273
|
+
// Try to dial using circuit relay multiaddr
|
|
274
|
+
let connected = false;
|
|
275
|
+
for (const addr of peer.multiaddrs) {
|
|
276
|
+
try {
|
|
277
|
+
const ma = multiaddr(addr);
|
|
278
|
+
await this.node.dial(ma as any);
|
|
279
|
+
connected = true;
|
|
280
|
+
console.log('[ByteCave] Refresh: ✓ Reconnected via:', addr.slice(0, 60) + '...');
|
|
281
|
+
break;
|
|
282
|
+
} catch (dialErr: any) {
|
|
283
|
+
console.debug('[ByteCave] Refresh: Failed to dial via', addr.slice(0, 50) + '...', dialErr.message);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (connected) {
|
|
288
|
+
// Fetch health data
|
|
289
|
+
try {
|
|
290
|
+
const health = await p2pProtocolClient.getHealthFromPeer(peer.peerId);
|
|
291
|
+
if (health) {
|
|
292
|
+
this.knownPeers.set(peer.peerId, {
|
|
293
|
+
peerId: peer.peerId,
|
|
294
|
+
publicKey: health.publicKey || '',
|
|
295
|
+
contentTypes: health.contentTypes || 'all',
|
|
296
|
+
connected: true,
|
|
297
|
+
nodeId: health.nodeId
|
|
298
|
+
});
|
|
299
|
+
console.log('[ByteCave] Refresh: ✓ Updated peer info:', health.nodeId || peer.peerId.slice(0, 12));
|
|
300
|
+
}
|
|
301
|
+
} catch (err: any) {
|
|
302
|
+
console.warn('[ByteCave] Refresh: Failed to get health from peer:', peer.peerId.slice(0, 12), err.message);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
console.debug('[ByteCave] Refresh: Peer already connected:', peer.peerId.slice(0, 12) + '...');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
break; // Successfully refreshed from one relay
|
|
311
|
+
}
|
|
312
|
+
} catch (err: any) {
|
|
313
|
+
console.warn('[ByteCave] Refresh: Failed to get directory from relay:', err.message);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.log('[ByteCave] Refresh complete. Connected peers:', this.node.getPeers().length, 'Known peers:', this.knownPeers.size);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Store data on the network
|
|
322
|
+
* Uses getPeers() directly for fast peer access
|
|
323
|
+
*
|
|
324
|
+
* @param data - Data to store
|
|
325
|
+
* @param mimeType - MIME type (optional, defaults to 'application/octet-stream')
|
|
326
|
+
* @param signer - Ethers signer for authorization (optional, but required for most nodes)
|
|
327
|
+
*/
|
|
328
|
+
async store(data: Uint8Array | ArrayBuffer, mimeType?: string, signer?: any): Promise<StoreResult> {
|
|
329
|
+
if (!this.node) {
|
|
330
|
+
return { success: false, error: 'P2P node not initialized' };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Get all connected peers (excluding relay)
|
|
334
|
+
const allPeers = this.node.getPeers();
|
|
335
|
+
const relayPeerIds = new Set(
|
|
336
|
+
this.config.relayPeers?.map(addr => addr.split('/p2p/').pop()) || []
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const connectedPeerIds = allPeers
|
|
340
|
+
.map(p => p.toString())
|
|
341
|
+
.filter(peerId => !relayPeerIds.has(peerId));
|
|
342
|
+
|
|
343
|
+
console.log('[ByteCave] Store - connected storage peers:', connectedPeerIds.length);
|
|
344
|
+
console.log('[ByteCave] Store - knownPeers with registration info:', this.knownPeers.size);
|
|
345
|
+
|
|
346
|
+
if (connectedPeerIds.length === 0) {
|
|
347
|
+
return { success: false, error: 'No storage peers available' };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Prioritize registered peers from knownPeers (populated via floodsub announcements)
|
|
351
|
+
// If no registered peers known yet, use all connected peers
|
|
352
|
+
const registeredPeerIds = Array.from(this.knownPeers.values())
|
|
353
|
+
.filter(p => p.isRegistered && connectedPeerIds.includes(p.peerId))
|
|
354
|
+
.map(p => p.peerId);
|
|
355
|
+
|
|
356
|
+
const storagePeerIds = registeredPeerIds.length > 0
|
|
357
|
+
? [...registeredPeerIds, ...connectedPeerIds.filter(id => !registeredPeerIds.includes(id))]
|
|
358
|
+
: connectedPeerIds;
|
|
359
|
+
|
|
360
|
+
console.log('[ByteCave] Store - peer order (registered first):',
|
|
361
|
+
storagePeerIds.map(id => id.slice(0, 12)).join(', '),
|
|
362
|
+
'(registered:', registeredPeerIds.length, ')');
|
|
363
|
+
|
|
364
|
+
const dataArray = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
365
|
+
|
|
366
|
+
// Validate file size (5MB limit)
|
|
367
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB in bytes
|
|
368
|
+
if (dataArray.length > MAX_FILE_SIZE) {
|
|
369
|
+
const sizeMB = (dataArray.length / (1024 * 1024)).toFixed(2);
|
|
370
|
+
return {
|
|
371
|
+
success: false,
|
|
372
|
+
error: `File size (${sizeMB}MB) exceeds maximum allowed size of 5MB`
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Create authorization if signer is provided
|
|
377
|
+
let authorization: any = undefined;
|
|
378
|
+
if (signer) {
|
|
379
|
+
try {
|
|
380
|
+
// Import ethers dynamically to avoid bundling issues
|
|
381
|
+
const { ethers } = await import('ethers');
|
|
382
|
+
|
|
383
|
+
const sender = await signer.getAddress();
|
|
384
|
+
const contentHash = ethers.keccak256(dataArray);
|
|
385
|
+
const timestamp = Date.now();
|
|
386
|
+
const nonce = Math.random().toString(36).substring(2, 15) +
|
|
387
|
+
Math.random().toString(36).substring(2, 15);
|
|
388
|
+
|
|
389
|
+
const message = `ByteCave Storage Request for:
|
|
390
|
+
Content Hash: ${contentHash}
|
|
391
|
+
App ID: ${this.config.appId}
|
|
392
|
+
Timestamp: ${timestamp}
|
|
393
|
+
Nonce: ${nonce}`;
|
|
394
|
+
const signature = await signer.signMessage(message);
|
|
395
|
+
|
|
396
|
+
authorization = {
|
|
397
|
+
sender,
|
|
398
|
+
signature,
|
|
399
|
+
timestamp,
|
|
400
|
+
nonce,
|
|
401
|
+
contentHash,
|
|
402
|
+
appId: this.config.appId
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
console.log('[ByteCave] Created authorization for storage request');
|
|
406
|
+
} catch (err: any) {
|
|
407
|
+
console.warn('[ByteCave] Failed to create authorization:', err.message);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Try each storage peer until one succeeds
|
|
412
|
+
const errors: string[] = [];
|
|
413
|
+
for (const peerId of storagePeerIds) {
|
|
414
|
+
console.log('[ByteCave] Attempting P2P store to peer:', peerId.slice(0, 12) + '...');
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const result = await p2pProtocolClient.storeToPeer(
|
|
418
|
+
peerId,
|
|
419
|
+
dataArray,
|
|
420
|
+
mimeType || 'application/octet-stream',
|
|
421
|
+
authorization,
|
|
422
|
+
false // shouldVerifyOnChain - false for browser test storage
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
if (result.success && result.cid) {
|
|
426
|
+
console.log('[ByteCave] ✓ P2P store successful:', result.cid);
|
|
427
|
+
return {
|
|
428
|
+
success: true,
|
|
429
|
+
cid: result.cid,
|
|
430
|
+
peerId
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const errorMsg = `${peerId.slice(0, 12)}: ${result.error}`;
|
|
435
|
+
console.warn('[ByteCave] ✗ P2P store failed:', errorMsg);
|
|
436
|
+
errors.push(errorMsg);
|
|
437
|
+
} catch (err: any) {
|
|
438
|
+
const errorMsg = `${peerId.slice(0, 12)}: ${err.message}`;
|
|
439
|
+
console.error('[ByteCave] ✗ P2P store exception:', errorMsg);
|
|
440
|
+
errors.push(errorMsg);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
console.error('[ByteCave] All storage peers failed. Errors:', errors);
|
|
445
|
+
return { success: false, error: `All storage peers failed: ${errors.join('; ')}` };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Retrieve ciphertext from a node via P2P only (no HTTP fallback)
|
|
450
|
+
*/
|
|
451
|
+
async retrieve(cid: string): Promise<RetrieveResult> {
|
|
452
|
+
if (!this.node) {
|
|
453
|
+
console.error('[ByteCave] Retrieve failed: P2P node not initialized');
|
|
454
|
+
return { success: false, error: 'P2P node not initialized' };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const libp2pPeers = this.node.getPeers();
|
|
458
|
+
console.log('[ByteCave] Retrieve - libp2p peers:', libp2pPeers.length);
|
|
459
|
+
console.log('[ByteCave] Retrieve - known peers:', this.knownPeers.size);
|
|
460
|
+
console.log('[ByteCave] Retrieve - node status:', this.node.status);
|
|
461
|
+
|
|
462
|
+
if (libp2pPeers.length === 0) {
|
|
463
|
+
console.warn('[ByteCave] Retrieve failed: No libp2p peers connected, but have', this.knownPeers.size, 'known peers');
|
|
464
|
+
return { success: false, error: 'No connected peers available' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Find which peers have this CID
|
|
468
|
+
const peersWithCid: string[] = [];
|
|
469
|
+
|
|
470
|
+
for (const peerId of libp2pPeers) {
|
|
471
|
+
const peerIdStr = peerId.toString();
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
const hasCid = await p2pProtocolClient.peerHasCid(peerIdStr, cid);
|
|
475
|
+
|
|
476
|
+
if (hasCid) {
|
|
477
|
+
peersWithCid.push(peerIdStr);
|
|
478
|
+
}
|
|
479
|
+
} catch (error: any) {
|
|
480
|
+
// Skip peers that don't support the protocol
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (peersWithCid.length === 0) {
|
|
485
|
+
return { success: false, error: 'Blob not found on any connected peer' };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Try to retrieve from peers that have the CID
|
|
489
|
+
for (const peerId of peersWithCid) {
|
|
490
|
+
try {
|
|
491
|
+
const timeoutPromise = new Promise<null>((_, reject) =>
|
|
492
|
+
setTimeout(() => reject(new Error('Retrieval timeout after 10s')), 10000)
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const result = await Promise.race([
|
|
496
|
+
p2pProtocolClient.retrieveFromPeer(peerId, cid),
|
|
497
|
+
timeoutPromise
|
|
498
|
+
]);
|
|
499
|
+
|
|
500
|
+
if (result) {
|
|
501
|
+
return { success: true, data: result.data, peerId };
|
|
502
|
+
}
|
|
503
|
+
} catch (error: any) {
|
|
504
|
+
// Continue to next peer
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return { success: false, error: 'Failed to retrieve blob from peers that have it' };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Register content in ContentRegistry contract (on-chain)
|
|
513
|
+
* This must be called before storing content that requires on-chain verification
|
|
514
|
+
*/
|
|
515
|
+
async registerContent(
|
|
516
|
+
cid: string,
|
|
517
|
+
appId: string,
|
|
518
|
+
signer: ethers.Signer
|
|
519
|
+
): Promise<{ success: boolean; txHash?: string; error?: string }> {
|
|
520
|
+
if (!this.config.contentRegistryAddress) {
|
|
521
|
+
return {
|
|
522
|
+
success: false,
|
|
523
|
+
error: 'ContentRegistry address not configured'
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (!this.config.rpcUrl) {
|
|
528
|
+
return {
|
|
529
|
+
success: false,
|
|
530
|
+
error: 'RPC URL not configured'
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
console.log('[ByteCave] Registering content in ContentRegistry:', { cid, appId });
|
|
536
|
+
|
|
537
|
+
const contract = new ethers.Contract(
|
|
538
|
+
this.config.contentRegistryAddress,
|
|
539
|
+
CONTENT_REGISTRY_ABI,
|
|
540
|
+
signer
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const tx = await contract.registerContent(cid, appId);
|
|
544
|
+
console.log('[ByteCave] ContentRegistry transaction sent:', tx.hash);
|
|
545
|
+
|
|
546
|
+
const receipt = await tx.wait();
|
|
547
|
+
console.log('[ByteCave] ContentRegistry registration confirmed:', receipt.hash);
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
success: true,
|
|
551
|
+
txHash: receipt.hash
|
|
552
|
+
};
|
|
553
|
+
} catch (error: any) {
|
|
554
|
+
console.error('[ByteCave] ContentRegistry registration failed:', error);
|
|
555
|
+
return {
|
|
556
|
+
success: false,
|
|
557
|
+
error: error.message || 'Registration failed'
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Check if content is registered in ContentRegistry
|
|
564
|
+
*/
|
|
565
|
+
async isContentRegistered(cid: string): Promise<boolean> {
|
|
566
|
+
if (!this.config.contentRegistryAddress || !this.config.rpcUrl) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
const provider = new ethers.JsonRpcProvider(this.config.rpcUrl);
|
|
572
|
+
const contract = new ethers.Contract(
|
|
573
|
+
this.config.contentRegistryAddress,
|
|
574
|
+
CONTENT_REGISTRY_ABI,
|
|
575
|
+
provider
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
return await contract.isContentRegistered(cid);
|
|
579
|
+
} catch (error: any) {
|
|
580
|
+
console.error('[ByteCave] Failed to check content registration:', error);
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Get list of known peers (includes both announced peers and connected libp2p peers)
|
|
587
|
+
*/
|
|
588
|
+
async getPeers(): Promise<PeerInfo[]> {
|
|
589
|
+
// Get the set of currently connected peer IDs
|
|
590
|
+
const connectedPeerIds = new Set<string>();
|
|
591
|
+
if (this.node) {
|
|
592
|
+
const libp2pPeers = this.node.getPeers();
|
|
593
|
+
console.log('[ByteCave] getPeers called - node exists:', !!this.node, 'libp2p peers:', libp2pPeers.length);
|
|
594
|
+
for (const peerId of libp2pPeers) {
|
|
595
|
+
connectedPeerIds.add(peerId.toString());
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
console.log('[ByteCave] getPeers called - NO NODE!');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Build result from known peers, marking connected status
|
|
602
|
+
const result: PeerInfo[] = [];
|
|
603
|
+
|
|
604
|
+
// Add all known peers with updated connected status
|
|
605
|
+
for (const [peerIdStr, peer] of this.knownPeers) {
|
|
606
|
+
result.push({
|
|
607
|
+
...peer,
|
|
608
|
+
connected: connectedPeerIds.has(peerIdStr)
|
|
609
|
+
});
|
|
610
|
+
connectedPeerIds.delete(peerIdStr); // Remove so we don't add twice
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Add any connected peers not in knownPeers
|
|
614
|
+
for (const peerIdStr of connectedPeerIds) {
|
|
615
|
+
result.push({
|
|
616
|
+
peerId: peerIdStr,
|
|
617
|
+
publicKey: '',
|
|
618
|
+
contentTypes: 'all',
|
|
619
|
+
connected: true
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
console.log('[ByteCave] getPeers - returning:', result.length, 'peers, connected:', result.filter(p => p.connected).length);
|
|
624
|
+
return result;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Get count of connected peers
|
|
629
|
+
*/
|
|
630
|
+
getConnectedPeerCount(): number {
|
|
631
|
+
return this.node ? this.node.getPeers().length : 0;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Get node info from a peer via P2P stream (for registration)
|
|
636
|
+
*/
|
|
637
|
+
async getNodeInfo(peerId: string): Promise<{ publicKey: string; ownerAddress?: string; peerId: string } | null> {
|
|
638
|
+
try {
|
|
639
|
+
const info = await p2pProtocolClient.getInfoFromPeer(peerId);
|
|
640
|
+
return info;
|
|
641
|
+
} catch (error: any) {
|
|
642
|
+
console.warn('[ByteCave] Failed to get node info via P2P:', error.message);
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Get health info from a peer via P2P stream
|
|
649
|
+
*/
|
|
650
|
+
async getNodeHealth(peerId: string): Promise<{
|
|
651
|
+
status: string;
|
|
652
|
+
blobCount: number;
|
|
653
|
+
storageUsed: number;
|
|
654
|
+
uptime: number;
|
|
655
|
+
} | null> {
|
|
656
|
+
try {
|
|
657
|
+
// Check if we have relay addresses for this peer
|
|
658
|
+
const peerInfo = this.knownPeers.get(peerId);
|
|
659
|
+
const relayAddrs = (peerInfo as any)?.relayAddrs;
|
|
660
|
+
|
|
661
|
+
// If we have relay addresses, dial through relay first
|
|
662
|
+
if (relayAddrs && relayAddrs.length > 0 && this.node) {
|
|
663
|
+
console.log('[ByteCave] Dialing peer through relay:', peerId.slice(0, 12), relayAddrs[0]);
|
|
664
|
+
try {
|
|
665
|
+
const ma = multiaddr(relayAddrs[0]);
|
|
666
|
+
await this.node.dial(ma as any);
|
|
667
|
+
console.log('[ByteCave] Successfully dialed peer through relay');
|
|
668
|
+
} catch (dialError) {
|
|
669
|
+
console.warn('[ByteCave] Failed to dial through relay:', dialError);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const health = await p2pProtocolClient.getHealthFromPeer(peerId);
|
|
674
|
+
return health;
|
|
675
|
+
} catch (error: any) {
|
|
676
|
+
console.warn('[ByteCave] Failed to get node health via P2P:', error.message);
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Get current connection state
|
|
683
|
+
*/
|
|
684
|
+
getConnectionState(): ConnectionState {
|
|
685
|
+
return this.connectionState;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Subscribe to events
|
|
690
|
+
*/
|
|
691
|
+
on(event: string, callback: Function): void {
|
|
692
|
+
if (!this.eventListeners.has(event)) {
|
|
693
|
+
this.eventListeners.set(event, new Set());
|
|
694
|
+
}
|
|
695
|
+
this.eventListeners.get(event)!.add(callback);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Unsubscribe from events
|
|
700
|
+
*/
|
|
701
|
+
off(event: string, callback: Function): void {
|
|
702
|
+
this.eventListeners.get(event)?.delete(callback);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private emit(event: string, data?: any): void {
|
|
706
|
+
this.eventListeners.get(event)?.forEach(cb => cb(data));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private setConnectionState(state: ConnectionState): void {
|
|
710
|
+
this.connectionState = state;
|
|
711
|
+
this.emit('connectionStateChange', state);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private setupEventListeners(): void {
|
|
715
|
+
if (!this.node) return;
|
|
716
|
+
|
|
717
|
+
this.node.addEventListener('peer:connect', (event) => {
|
|
718
|
+
const peerId = event.detail.toString();
|
|
719
|
+
console.log('[ByteCave] Peer connected:', peerId, 'Total now:', this.node?.getPeers().length);
|
|
720
|
+
this.emit('peerConnect', peerId);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
this.node.addEventListener('peer:disconnect', (event) => {
|
|
724
|
+
const peerId = event.detail.toString();
|
|
725
|
+
console.log('[ByteCave] Peer disconnected:', peerId);
|
|
726
|
+
console.log('[ByteCave] Remaining peers:', this.node?.getPeers().length);
|
|
727
|
+
|
|
728
|
+
const peer = this.knownPeers.get(peerId);
|
|
729
|
+
if (peer) {
|
|
730
|
+
peer.connected = false;
|
|
731
|
+
}
|
|
732
|
+
this.emit('peerDisconnect', peerId);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private async setupPubsub(): Promise<void> {
|
|
737
|
+
if (!this.node) return;
|
|
738
|
+
|
|
739
|
+
const pubsub = this.node.services.pubsub as any;
|
|
740
|
+
if (!pubsub) return;
|
|
741
|
+
|
|
742
|
+
// Subscribe to announcement topic
|
|
743
|
+
pubsub.subscribe(ANNOUNCE_TOPIC);
|
|
744
|
+
|
|
745
|
+
// Subscribe to our own signaling topic
|
|
746
|
+
const mySignalingTopic = `${SIGNALING_TOPIC_PREFIX}${this.node.peerId.toString()}`;
|
|
747
|
+
pubsub.subscribe(mySignalingTopic);
|
|
748
|
+
|
|
749
|
+
pubsub.addEventListener('message', (event: any) => {
|
|
750
|
+
const topic = event.detail.topic;
|
|
751
|
+
console.log('[ByteCave] Received floodsub message on topic:', topic);
|
|
752
|
+
|
|
753
|
+
if (topic === ANNOUNCE_TOPIC) {
|
|
754
|
+
try {
|
|
755
|
+
const data = toString(event.detail.data);
|
|
756
|
+
const announcement = JSON.parse(data);
|
|
757
|
+
console.log('[ByteCave] Received peer announcement:', announcement.peerId?.slice(0, 12));
|
|
758
|
+
this.handlePeerAnnouncement(announcement);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
console.warn('Failed to parse announcement:', error);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (topic === mySignalingTopic) {
|
|
765
|
+
try {
|
|
766
|
+
const data = toString(event.detail.data);
|
|
767
|
+
const signal: SignalingMessage = JSON.parse(data);
|
|
768
|
+
this.handleSignalingMessage(signal);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
console.warn('Failed to parse signaling message:', error);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private async handlePeerAnnouncement(announcement: {
|
|
777
|
+
peerId: string;
|
|
778
|
+
timestamp?: number;
|
|
779
|
+
relayAddrs?: string[];
|
|
780
|
+
contentTypes?: string[] | 'all';
|
|
781
|
+
}): Promise<void> {
|
|
782
|
+
console.log('[ByteCave] Received announcement from peer:', announcement.peerId.slice(0, 12), announcement);
|
|
783
|
+
|
|
784
|
+
const existing = this.knownPeers.get(announcement.peerId);
|
|
785
|
+
|
|
786
|
+
const peerInfo: PeerInfo = {
|
|
787
|
+
peerId: announcement.peerId,
|
|
788
|
+
publicKey: existing?.publicKey || '',
|
|
789
|
+
contentTypes: announcement.contentTypes || 'all',
|
|
790
|
+
connected: this.node?.getPeers().some(p => p.toString() === announcement.peerId) || false
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// Preserve relay addresses from existing peer info if new announcement doesn't have them
|
|
794
|
+
if (announcement.relayAddrs && announcement.relayAddrs.length > 0) {
|
|
795
|
+
(peerInfo as any).relayAddrs = announcement.relayAddrs;
|
|
796
|
+
} else if (existing && (existing as any).relayAddrs) {
|
|
797
|
+
(peerInfo as any).relayAddrs = (existing as any).relayAddrs;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
this.knownPeers.set(announcement.peerId, peerInfo);
|
|
801
|
+
|
|
802
|
+
this.emit('peerAnnounce', peerInfo);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Check if a nodeId is registered in the on-chain registry
|
|
808
|
+
*/
|
|
809
|
+
private async checkNodeRegistration(nodeId: string): Promise<boolean> {
|
|
810
|
+
if (!this.discovery) {
|
|
811
|
+
// No contract configured - skip registration check
|
|
812
|
+
return true;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
try {
|
|
816
|
+
const registeredNodes = await this.discovery.getActiveNodes();
|
|
817
|
+
return registeredNodes.some(node => node.nodeId === nodeId);
|
|
818
|
+
} catch (error) {
|
|
819
|
+
console.warn('[ByteCave] Failed to check node registration:', error);
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private handleSignalingMessage(signal: SignalingMessage): void {
|
|
825
|
+
console.log('Received signaling message:', signal.type, 'from:', signal.from);
|
|
826
|
+
this.emit('signaling', signal);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Send signaling message to a peer for WebRTC negotiation
|
|
831
|
+
*/
|
|
832
|
+
async sendSignalingMessage(targetPeerId: string, signal: Omit<SignalingMessage, 'from'>): Promise<void> {
|
|
833
|
+
if (!this.node) return;
|
|
834
|
+
|
|
835
|
+
const pubsub = this.node.services.pubsub as any;
|
|
836
|
+
if (!pubsub) return;
|
|
837
|
+
|
|
838
|
+
const targetTopic = `${SIGNALING_TOPIC_PREFIX}${targetPeerId}`;
|
|
839
|
+
const message: SignalingMessage = {
|
|
840
|
+
...signal,
|
|
841
|
+
from: this.node.peerId.toString()
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
await pubsub.publish(targetTopic, fromString(JSON.stringify(message)));
|
|
846
|
+
} catch (error) {
|
|
847
|
+
console.warn('Failed to send signaling message:', error);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private findPeerForContentType(contentType: string): PeerInfo | null {
|
|
852
|
+
// First pass: find registered peers that accept this content type
|
|
853
|
+
for (const peer of this.knownPeers.values()) {
|
|
854
|
+
if (!peer.connected) continue;
|
|
855
|
+
if (!peer.isRegistered) continue; // Prefer registered nodes for storage
|
|
856
|
+
if (peer.contentTypes === 'all') return peer;
|
|
857
|
+
if (Array.isArray(peer.contentTypes) && peer.contentTypes.includes(contentType)) {
|
|
858
|
+
return peer;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Second pass: fall back to any connected peer (for local-only storage)
|
|
863
|
+
// This allows personal nodes to work, but data won't replicate
|
|
864
|
+
for (const peer of this.knownPeers.values()) {
|
|
865
|
+
if (!peer.connected) continue;
|
|
866
|
+
if (peer.contentTypes === 'all') return peer;
|
|
867
|
+
if (Array.isArray(peer.contentTypes) && peer.contentTypes.includes(contentType)) {
|
|
868
|
+
console.warn('[ByteCave] No registered peers available, using unregistered peer. Data will NOT replicate.');
|
|
869
|
+
return peer;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
}
|