@sschepis/magazine 0.1.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.
@@ -0,0 +1,461 @@
1
+ import * as ethers from 'ethers';
2
+ import Logger from './Logger.js';
3
+
4
+ /**
5
+ * LRUCache - Simple Least Recently Used cache using Map ordering
6
+ */
7
+ class LRUCache {
8
+ constructor(maxSize = 500) { this.maxSize = maxSize; this.cache = new Map(); }
9
+ get(key) { if (!this.cache.has(key)) return undefined; const v = this.cache.get(key); this.cache.delete(key); this.cache.set(key, v); return v; }
10
+ set(key, value) { if (this.cache.has(key)) this.cache.delete(key); else if (this.cache.size >= this.maxSize) this.cache.delete(this.cache.keys().next().value); this.cache.set(key, value); }
11
+ clear() { this.cache.clear(); }
12
+ get size() { return this.cache.size; }
13
+ }
14
+
15
+ /**
16
+ * RateLimiter - Token-bucket rate limiter for throttling RPC calls
17
+ */
18
+ class RateLimiter {
19
+ constructor(maxPerSecond = 25) {
20
+ this.maxPerSecond = maxPerSecond;
21
+ this.tokens = maxPerSecond;
22
+ this.lastRefill = Date.now();
23
+ this.queue = [];
24
+ }
25
+ async acquire() {
26
+ this._refill();
27
+ if (this.tokens > 0) { this.tokens--; return; }
28
+ return new Promise(resolve => this.queue.push(resolve));
29
+ }
30
+ _refill() {
31
+ const now = Date.now();
32
+ const elapsed = (now - this.lastRefill) / 1000;
33
+ this.tokens = Math.min(this.maxPerSecond, this.tokens + elapsed * this.maxPerSecond);
34
+ this.lastRefill = now;
35
+ while (this.queue.length > 0 && this.tokens > 0) { this.tokens--; this.queue.shift()(); }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * NetworkManager - Handles provider and network management
41
+ *
42
+ * Supports multi-provider failover, LRU caching for blocks/timestamps,
43
+ * and token-bucket rate limiting for RPC calls.
44
+ */
45
+ export default class NetworkManager {
46
+ constructor(config, retryOperation) {
47
+ this.config = config;
48
+ this.retryOperation = retryOperation;
49
+ this.logger = new Logger({
50
+ context: 'NetworkManager',
51
+ level: config?.logLevel || 'info'
52
+ });
53
+
54
+ this.logger.debug('Initializing NetworkManager', {
55
+ provider: config.provider,
56
+ networkId: config.networkId
57
+ });
58
+
59
+ // Multi-provider failover setup
60
+ this.providers = [];
61
+ this.currentProviderIndex = 0;
62
+ this._initializeProviders(config.provider);
63
+
64
+ // Set the active provider to the first one
65
+ this.provider = this.providers[0];
66
+
67
+ this.interface = new ethers.Interface(config.abi);
68
+
69
+ // Initialize LRU caches for blocks and timestamps
70
+ const cacheSize = config.cacheSize || 500;
71
+ this.blockCache = new LRUCache(cacheSize);
72
+ this.timestampCache = new LRUCache(cacheSize);
73
+ this.logger.debug('LRU caches initialized', { cacheSize });
74
+
75
+ // Initialize rate limiter
76
+ const rateLimit = config.rateLimit || 25;
77
+ this.rateLimiter = new RateLimiter(rateLimit);
78
+ this.logger.debug('Rate limiter initialized', { maxPerSecond: rateLimit });
79
+
80
+ // Initialize event mappings
81
+ this.events = {};
82
+ this.eventsByTopic = {}; // Map topic hash to event name
83
+
84
+ this._initializeEvents();
85
+
86
+ this.logger.info('NetworkManager initialized successfully', {
87
+ eventCount: Object.keys(this.eventsByTopic).length,
88
+ contractAddress: config.address,
89
+ providerCount: this.providers.length
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Initialize providers from config. Accepts a single provider (string or
95
+ * ethers provider object) or an array of providers.
96
+ */
97
+ _initializeProviders(providerConfig) {
98
+ const providerList = Array.isArray(providerConfig) ? providerConfig : [providerConfig];
99
+
100
+ for (const entry of providerList) {
101
+ if (typeof entry === 'string') {
102
+ const provider = new ethers.JsonRpcProvider(entry);
103
+ this.providers.push(provider);
104
+ this.logger.debug('Created JsonRpcProvider from URL', { url: entry });
105
+ } else if (entry && typeof entry.getNetwork === 'function') {
106
+ this.providers.push(entry);
107
+ this.logger.debug('Using provided ethers provider object', {
108
+ providerType: entry.constructor.name
109
+ });
110
+ } else {
111
+ throw new Error('Each provider must be either a string URL or an ethers provider object');
112
+ }
113
+ }
114
+
115
+ if (this.providers.length === 0) {
116
+ throw new Error('At least one provider must be specified');
117
+ }
118
+
119
+ this.logger.debug('Providers initialized', { count: this.providers.length });
120
+ }
121
+
122
+ /**
123
+ * Switch to the next provider in the list on failure.
124
+ * Returns true if a new provider was activated, false if all have been tried.
125
+ */
126
+ _switchProvider(error) {
127
+ const previousIndex = this.currentProviderIndex;
128
+ this.currentProviderIndex = (this.currentProviderIndex + 1) % this.providers.length;
129
+
130
+ // If we've cycled back to the starting provider, all have been tried
131
+ if (this.currentProviderIndex === previousIndex && this.providers.length === 1) {
132
+ this.logger.error('Single provider failed, no failover available', {
133
+ error: error.message
134
+ });
135
+ return false;
136
+ }
137
+
138
+ this.provider = this.providers[this.currentProviderIndex];
139
+ this.logger.warn('Switching provider due to failure', {
140
+ error: error.message,
141
+ previousIndex,
142
+ newIndex: this.currentProviderIndex,
143
+ totalProviders: this.providers.length
144
+ });
145
+
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Execute a function through the rate limiter.
151
+ * Acquires a token before executing the call.
152
+ */
153
+ async _throttledCall(fn) {
154
+ await this.rateLimiter.acquire();
155
+ return fn();
156
+ }
157
+
158
+ /**
159
+ * Execute a provider call with failover support.
160
+ * Tries each provider in sequence before giving up.
161
+ */
162
+ async _callWithFailover(fn, description) {
163
+ const startIndex = this.currentProviderIndex;
164
+ let lastError;
165
+ let attempts = 0;
166
+
167
+ do {
168
+ try {
169
+ return await this._throttledCall(() => fn(this.provider));
170
+ } catch (error) {
171
+ lastError = error;
172
+ attempts++;
173
+ this.logger.warn('Provider call failed', {
174
+ description,
175
+ providerIndex: this.currentProviderIndex,
176
+ attempt: attempts,
177
+ error: error.message
178
+ });
179
+
180
+ if (this.providers.length > 1) {
181
+ this._switchProvider(error);
182
+ // If we've rotated back to the start, stop
183
+ if (this.currentProviderIndex === startIndex) {
184
+ break;
185
+ }
186
+ } else {
187
+ break;
188
+ }
189
+ }
190
+ } while (true);
191
+
192
+ throw lastError;
193
+ }
194
+
195
+ // Initialize event mappings from ABI
196
+ _initializeEvents() {
197
+ let eventCount = 0;
198
+
199
+ for (const fragment of this.config.abi) {
200
+ if (fragment.type === 'event') {
201
+ const eventName = fragment.name;
202
+ this.events[eventName] = new ethers.Interface([fragment]);
203
+
204
+ // Create mapping from topic hash to event name
205
+ try {
206
+ const eventFragment = this.interface.getEvent(eventName);
207
+ const topicHash = eventFragment.topicHash;
208
+ this.eventsByTopic[topicHash] = eventName;
209
+ eventCount++;
210
+
211
+ this.logger.debug('Event mapping created', {
212
+ eventName,
213
+ topicHash
214
+ });
215
+ } catch (error) {
216
+ this.logger.warn('Failed to get topic hash for event', {
217
+ eventName,
218
+ error: error.message
219
+ });
220
+ }
221
+ }
222
+ }
223
+
224
+ this.logger.debug('Event initialization completed', { eventCount });
225
+ }
226
+
227
+ // Get the latest block number from the network
228
+ async getLatestBlockNumber() {
229
+ return this.logger.time('getLatestBlockNumber', async () => {
230
+ const blockNumber = await this.retryOperation(
231
+ () => this._callWithFailover(
232
+ (provider) => provider.getBlockNumber(),
233
+ 'getLatestBlockNumber'
234
+ ),
235
+ 'Getting latest block number'
236
+ );
237
+
238
+ this.logger.logNetworkActivity('getLatestBlockNumber', { blockNumber });
239
+ return blockNumber;
240
+ });
241
+ }
242
+
243
+ // Get logs from the blockchain within a block range
244
+ async getLogs(fromBlock, toBlock) {
245
+ return this.logger.time('getLogs', async () => {
246
+ this.logger.logNetworkActivity('getLogs_request', { fromBlock, toBlock, address: this.config.address });
247
+
248
+ const logs = await this.retryOperation(
249
+ () => this._callWithFailover(
250
+ (provider) => provider.getLogs({
251
+ address: this.config.address,
252
+ fromBlock,
253
+ toBlock
254
+ }),
255
+ 'getLogs'
256
+ ),
257
+ `Getting logs for blocks ${fromBlock}-${toBlock}`
258
+ );
259
+
260
+ this.logger.logNetworkActivity('getLogs_response', {
261
+ fromBlock,
262
+ toBlock,
263
+ logCount: logs.length
264
+ });
265
+
266
+ return logs;
267
+ });
268
+ }
269
+
270
+ // Parse a raw log into structured event data
271
+ parseLog(log) {
272
+ const topicHash = log.topics[0];
273
+ const eventName = this.eventsByTopic[topicHash];
274
+
275
+ if (!eventName) {
276
+ this.logger.trace('Unknown event topic', { topicHash, availableTopics: Object.keys(this.eventsByTopic) });
277
+ return null;
278
+ }
279
+
280
+ try {
281
+ const parsedLog = this.interface.parseLog(log);
282
+ const result = {
283
+ eventName,
284
+ parsedLog,
285
+ eventData: {
286
+ ...parsedLog.args,
287
+ blockNumber: log.blockNumber,
288
+ transactionHash: log.transactionHash,
289
+ logIndex: log.logIndex,
290
+ timestamp: Date.now()
291
+ }
292
+ };
293
+
294
+ this.logger.debug('Successfully parsed log', {
295
+ eventName,
296
+ blockNumber: log.blockNumber,
297
+ transactionHash: log.transactionHash,
298
+ logIndex: log.logIndex
299
+ });
300
+
301
+ return result;
302
+ } catch (error) {
303
+ this.logger.warn('Failed to parse log for event', {
304
+ eventName,
305
+ blockNumber: log.blockNumber,
306
+ transactionHash: log.transactionHash,
307
+ error: error.message
308
+ });
309
+ return null;
310
+ }
311
+ }
312
+
313
+ // Create unique event key for deduplication
314
+ createEventKey(log) {
315
+ return `${log.transactionHash}-${log.logIndex}`;
316
+ }
317
+
318
+ // Get network information
319
+ async getNetworkInfo() {
320
+ return this.retryOperation(async () => {
321
+ const network = await this._callWithFailover(
322
+ (provider) => provider.getNetwork(),
323
+ 'getNetwork'
324
+ );
325
+ const blockNumber = await this._callWithFailover(
326
+ (provider) => provider.getBlockNumber(),
327
+ 'getBlockNumber'
328
+ );
329
+
330
+ return {
331
+ chainId: Number(network.chainId),
332
+ name: network.name,
333
+ currentBlock: blockNumber,
334
+ expectedNetworkId: this.config.networkId
335
+ };
336
+ }, 'Getting network information');
337
+ }
338
+
339
+ // Validate network connection and configuration
340
+ async validateNetwork() {
341
+ const networkInfo = await this.getNetworkInfo();
342
+
343
+ if (networkInfo.chainId !== this.config.networkId) {
344
+ throw new Error(
345
+ `Network mismatch: expected ${this.config.networkId}, got ${networkInfo.chainId}`
346
+ );
347
+ }
348
+
349
+ return networkInfo;
350
+ }
351
+
352
+ // Get contract code to verify contract exists
353
+ async validateContract() {
354
+ return this.retryOperation(async () => {
355
+ const code = await this._callWithFailover(
356
+ (provider) => provider.getCode(this.config.address),
357
+ 'getCode'
358
+ );
359
+ if (code === '0x') {
360
+ throw new Error(`No contract found at address ${this.config.address}`);
361
+ }
362
+ return true;
363
+ }, 'Validating contract');
364
+ }
365
+
366
+ // Get a block by number, with LRU caching
367
+ async getBlock(blockNumber) {
368
+ const cached = this.blockCache.get(blockNumber);
369
+ if (cached !== undefined) {
370
+ this.logger.debug('Block cache hit', { blockNumber });
371
+ return cached;
372
+ }
373
+
374
+ const block = await this.retryOperation(async () => {
375
+ return this._callWithFailover(
376
+ (provider) => provider.getBlock(blockNumber),
377
+ `getBlock(${blockNumber})`
378
+ );
379
+ }, `Getting block ${blockNumber}`);
380
+
381
+ if (block) {
382
+ this.blockCache.set(blockNumber, block);
383
+ }
384
+
385
+ return block;
386
+ }
387
+
388
+ // Get block timestamp, with LRU caching
389
+ async getBlockTimestamp(blockNumber) {
390
+ const cached = this.timestampCache.get(blockNumber);
391
+ if (cached !== undefined) {
392
+ this.logger.debug('Timestamp cache hit', { blockNumber });
393
+ return cached;
394
+ }
395
+
396
+ const block = await this.getBlock(blockNumber);
397
+ const timestamp = block ? block.timestamp : null;
398
+
399
+ if (timestamp !== null) {
400
+ this.timestampCache.set(blockNumber, timestamp);
401
+ }
402
+
403
+ return timestamp;
404
+ }
405
+
406
+ // Get transaction receipt
407
+ async getTransactionReceipt(txHash) {
408
+ return this.retryOperation(
409
+ () => this._callWithFailover(
410
+ (provider) => provider.getTransactionReceipt(txHash),
411
+ 'getTransactionReceipt'
412
+ ),
413
+ `Getting transaction receipt for ${txHash}`
414
+ );
415
+ }
416
+
417
+ // Get gas price information
418
+ async getGasPrice() {
419
+ return this.retryOperation(
420
+ () => this._callWithFailover(
421
+ (provider) => provider.getFeeData(),
422
+ 'getFeeData'
423
+ ),
424
+ 'Getting gas price information'
425
+ );
426
+ }
427
+
428
+ // Get provider instance for advanced operations
429
+ getProvider() {
430
+ return this.provider;
431
+ }
432
+
433
+ // Get ethers interface for manual parsing
434
+ getInterface() {
435
+ return this.interface;
436
+ }
437
+
438
+ // Get event mappings
439
+ getEventMappings() {
440
+ return {
441
+ events: { ...this.events },
442
+ eventsByTopic: { ...this.eventsByTopic }
443
+ };
444
+ }
445
+
446
+ // Clear all caches
447
+ clearCaches() {
448
+ this.blockCache.clear();
449
+ this.timestampCache.clear();
450
+ this.logger.debug('All caches cleared');
451
+ }
452
+
453
+ // Get cache statistics
454
+ getCacheStats() {
455
+ return {
456
+ blockCacheSize: this.blockCache.size,
457
+ timestampCacheSize: this.timestampCache.size,
458
+ maxCacheSize: this.blockCache.maxSize
459
+ };
460
+ }
461
+ }