@monygroupcorp/micro-web3 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,348 @@
1
+ class ContractCache {
2
+ constructor(eventBus) {
3
+ if (!eventBus) {
4
+ throw new Error('ContractCache requires an eventBus instance.');
5
+ }
6
+ this.eventBus = eventBus;
7
+ // Cache storage: Map<key, {value, expiresAt, accessCount}>
8
+ this.cache = new Map();
9
+
10
+ // Cache statistics
11
+ this.stats = {
12
+ hits: 0,
13
+ misses: 0,
14
+ invalidations: 0,
15
+ sets: 0
16
+ };
17
+
18
+ // Default TTL per method type (in milliseconds)
19
+ this.defaultTTLs = {
20
+ // Price data changes frequently but can be cached briefly
21
+ price: 5000, // 5 seconds
22
+ // Balances should be fresh but can cache briefly
23
+ balance: 3000, // 3 seconds
24
+ // Contract data changes less frequently
25
+ contractData: 10000, // 10 seconds
26
+ // Supply data changes less frequently
27
+ supply: 10000, // 10 seconds
28
+ // NFT metadata rarely changes
29
+ metadata: 60000, // 60 seconds
30
+ // Tier/whitelist data changes infrequently
31
+ tier: 30000, // 30 seconds
32
+ // Default TTL
33
+ default: 5000 // 5 seconds
34
+ };
35
+
36
+ // Methods that should never be cached
37
+ this.noCacheMethods = new Set([
38
+ 'executeContractCall', // Transactions
39
+ 'swapExactETHForTokens',
40
+ 'swapExactTokensForETH',
41
+ 'buy',
42
+ 'sell',
43
+ 'mint'
44
+ ]);
45
+
46
+ // Setup event listeners for cache invalidation
47
+ this.setupEventListeners();
48
+
49
+ // Debug mode
50
+ this.debug = false;
51
+ }
52
+
53
+ /**
54
+ * Setup event listeners for automatic cache invalidation
55
+ */
56
+ setupEventListeners() {
57
+ // Invalidate on transaction confirmation
58
+ this.eventBus.on('transaction:confirmed', () => {
59
+ this.invalidateByPattern('balance', 'price', 'contractData');
60
+ if (this.debug) {
61
+ console.log('[ContractCache] Invalidated cache due to transaction confirmation');
62
+ }
63
+ });
64
+
65
+ // Invalidate on account change
66
+ this.eventBus.on('account:changed', () => {
67
+ this.invalidateByPattern('balance');
68
+ if (this.debug) {
69
+ console.log('[ContractCache] Invalidated cache due to account change');
70
+ }
71
+ });
72
+
73
+ // Invalidate on network change
74
+ this.eventBus.on('network:changed', () => {
75
+ this.clear(); // Clear all cache on network change
76
+ if (this.debug) {
77
+ console.log('[ContractCache] Cleared cache due to network change');
78
+ }
79
+ });
80
+
81
+ // Invalidate on contract data update
82
+ this.eventBus.on('contractData:updated', () => {
83
+ this.invalidateByPattern('contractData', 'price', 'supply');
84
+ if (this.debug) {
85
+ console.log('[ContractCache] Invalidated cache due to contract data update');
86
+ }
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Generate cache key from method name and arguments
92
+ * @param {string} method - Method name
93
+ * @param {Array} args - Method arguments
94
+ * @returns {string} - Cache key
95
+ */
96
+ generateKey(method, args = []) {
97
+ // Create a stable key from method and args
98
+ const argsKey = args.length > 0
99
+ ? JSON.stringify(args.map(arg => {
100
+ // Handle BigNumber and other special types
101
+ if (arg && typeof arg === 'object' && arg.toString) {
102
+ return arg.toString();
103
+ }
104
+ return arg;
105
+ }))
106
+ : '';
107
+ return `${method}:${argsKey}`;
108
+ }
109
+
110
+ /**
111
+ * Get cached value if available and not expired
112
+ * @param {string} method - Method name
113
+ * @param {Array} args - Method arguments
114
+ * @returns {any|null} - Cached value or null if not found/expired
115
+ */
116
+ get(method, args = []) {
117
+ // Don't cache certain methods
118
+ if (this.noCacheMethods.has(method)) {
119
+ return null;
120
+ }
121
+
122
+ const key = this.generateKey(method, args);
123
+ const entry = this.cache.get(key);
124
+
125
+ if (!entry) {
126
+ this.stats.misses++;
127
+ return null;
128
+ }
129
+
130
+ // Check if expired
131
+ if (Date.now() > entry.expiresAt) {
132
+ this.cache.delete(key);
133
+ this.stats.misses++;
134
+ return null;
135
+ }
136
+
137
+ // Update access count
138
+ entry.accessCount++;
139
+ this.stats.hits++;
140
+
141
+ if (this.debug) {
142
+ console.log(`[ContractCache] Cache HIT for ${method}`, args);
143
+ }
144
+
145
+ return entry.value;
146
+ }
147
+
148
+ /**
149
+ * Set cached value with TTL
150
+ * @param {string} method - Method name
151
+ * @param {Array} args - Method arguments
152
+ * @param {any} value - Value to cache
153
+ * @param {number} ttl - Time to live in milliseconds (optional, uses default if not provided)
154
+ */
155
+ set(method, args = [], value, ttl = null) {
156
+ // Don't cache certain methods
157
+ if (this.noCacheMethods.has(method)) {
158
+ return;
159
+ }
160
+
161
+ // Determine TTL based on method type
162
+ if (ttl === null) {
163
+ ttl = this.getTTLForMethod(method);
164
+ }
165
+
166
+ const key = this.generateKey(method, args);
167
+ const expiresAt = Date.now() + ttl;
168
+
169
+ this.cache.set(key, {
170
+ value,
171
+ expiresAt,
172
+ accessCount: 0,
173
+ method,
174
+ args,
175
+ cachedAt: Date.now()
176
+ });
177
+
178
+ this.stats.sets++;
179
+
180
+ if (this.debug) {
181
+ console.log(`[ContractCache] Cached ${method}`, args, `TTL: ${ttl}ms`);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Get TTL for a method based on its type
187
+ * @param {string} method - Method name
188
+ * @returns {number} - TTL in milliseconds
189
+ */
190
+ getTTLForMethod(method) {
191
+ const methodLower = method.toLowerCase();
192
+
193
+ // Price-related methods
194
+ if (methodLower.includes('price') || methodLower.includes('cost')) {
195
+ return this.defaultTTLs.price;
196
+ }
197
+
198
+ // Balance-related methods
199
+ if (methodLower.includes('balance')) {
200
+ return this.defaultTTLs.balance;
201
+ }
202
+
203
+ // Supply-related methods
204
+ if (methodLower.includes('supply') || methodLower.includes('total')) {
205
+ return this.defaultTTLs.supply;
206
+ }
207
+
208
+ // Metadata-related methods
209
+ if (methodLower.includes('metadata') || methodLower.includes('uri')) {
210
+ return this.defaultTTLs.metadata;
211
+ }
212
+
213
+ // Tier/whitelist-related methods
214
+ if (methodLower.includes('tier') || methodLower.includes('merkle') || methodLower.includes('proof')) {
215
+ return this.defaultTTLs.tier;
216
+ }
217
+
218
+ // Contract data methods
219
+ if (methodLower.includes('contract') || methodLower.includes('pool') || methodLower.includes('liquidity')) {
220
+ return this.defaultTTLs.contractData;
221
+ }
222
+
223
+ return this.defaultTTLs.default;
224
+ }
225
+
226
+ /**
227
+ * Invalidate cache entries matching patterns
228
+ * @param {...string} patterns - Patterns to match against method names
229
+ */
230
+ invalidateByPattern(...patterns) {
231
+ let invalidated = 0;
232
+
233
+ for (const [key, entry] of this.cache.entries()) {
234
+ const methodLower = entry.method.toLowerCase();
235
+
236
+ if (patterns.some(pattern => methodLower.includes(pattern.toLowerCase()))) {
237
+ this.cache.delete(key);
238
+ invalidated++;
239
+ }
240
+ }
241
+
242
+ this.stats.invalidations += invalidated;
243
+
244
+ if (this.debug && invalidated > 0) {
245
+ console.log(`[ContractCache] Invalidated ${invalidated} entries matching patterns:`, patterns);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Invalidate specific cache entry
251
+ * @param {string} method - Method name
252
+ * @param {Array} args - Method arguments
253
+ */
254
+ invalidate(method, args = []) {
255
+ const key = this.generateKey(method, args);
256
+ if (this.cache.delete(key)) {
257
+ this.stats.invalidations++;
258
+ if (this.debug) {
259
+ console.log(`[ContractCache] Invalidated ${method}`, args);
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Clear all cache
266
+ */
267
+ clear() {
268
+ const size = this.cache.size;
269
+ this.cache.clear();
270
+ this.stats.invalidations += size;
271
+
272
+ if (this.debug) {
273
+ console.log('[ContractCache] Cleared all cache');
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Get cache statistics
279
+ * @returns {Object} - Cache statistics
280
+ */
281
+ getStats() {
282
+ const total = this.stats.hits + this.stats.misses;
283
+ const hitRate = total > 0 ? (this.stats.hits / total * 100).toFixed(2) : 0;
284
+
285
+ return {
286
+ ...this.stats,
287
+ hitRate: `${hitRate}%`,
288
+ cacheSize: this.cache.size,
289
+ totalRequests: total
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Warm cache by pre-fetching common data
295
+ * @param {Function} fetchFn - Function to fetch data
296
+ * @param {Array} methods - Methods to warm
297
+ */
298
+ async warmCache(fetchFn, methods = []) {
299
+ if (this.debug) {
300
+ console.log('[ContractCache] Warming cache for methods:', methods);
301
+ }
302
+
303
+ const promises = methods.map(async (method) => {
304
+ try {
305
+ const result = await fetchFn(method);
306
+ // Cache will be set by the wrapped method
307
+ return result;
308
+ } catch (error) {
309
+ console.warn(`[ContractCache] Failed to warm cache for ${method}:`, error);
310
+ return null;
311
+ }
312
+ });
313
+
314
+ await Promise.all(promises);
315
+ }
316
+
317
+ /**
318
+ * Enable or disable debug logging
319
+ * @param {boolean} enabled - Whether to enable debug mode
320
+ */
321
+ setDebug(enabled) {
322
+ this.debug = enabled;
323
+ }
324
+
325
+ /**
326
+ * Clean up expired entries (should be called periodically)
327
+ */
328
+ cleanup() {
329
+ const now = Date.now();
330
+ let cleaned = 0;
331
+
332
+ for (const [key, entry] of this.cache.entries()) {
333
+ if (now > entry.expiresAt) {
334
+ this.cache.delete(key);
335
+ cleaned++;
336
+ }
337
+ }
338
+
339
+ if (this.debug && cleaned > 0) {
340
+ console.log(`[ContractCache] Cleaned up ${cleaned} expired entries`);
341
+ }
342
+
343
+ return cleaned;
344
+ }
345
+ }
346
+
347
+ export default ContractCache;
348
+
@@ -0,0 +1,249 @@
1
+ /**
2
+ * IPFS Service
3
+ *
4
+ * Light client for IPFS resolution with gateway rotation and fallback.
5
+ * Handles both direct IPFS URIs and metadata fetching.
6
+ *
7
+ * No secrets, no backend - pure client-side IPFS gateway resolution.
8
+ */
9
+
10
+ /**
11
+ * Public IPFS gateways (in order of preference)
12
+ * These are public gateways that don't require authentication
13
+ */
14
+ const IPFS_GATEWAYS = [
15
+ 'https://w3s.link/ipfs/',
16
+ 'https://cloudflare-ipfs.com/ipfs/',
17
+ 'https://ipfs.io/ipfs/',
18
+ 'https://gateway.pinata.cloud/ipfs/',
19
+ 'https://dweb.link/ipfs/'
20
+ ];
21
+
22
+ /**
23
+ * Get custom IPFS gateway from localStorage (user preference)
24
+ * @returns {string|null} Custom gateway base URL or null
25
+ */
26
+ function getCustomGateway() {
27
+ try {
28
+ const custom = localStorage.getItem('customIpfsGatewayBaseUrl');
29
+ if (custom && typeof custom === 'string' && custom.trim()) {
30
+ const trimmed = custom.trim();
31
+ // Ensure it ends with /ipfs/ or just /
32
+ if (trimmed.endsWith('/ipfs/')) {
33
+ return trimmed;
34
+ } else if (trimmed.endsWith('/')) {
35
+ return trimmed + 'ipfs/';
36
+ } else {
37
+ return trimmed + '/ipfs/';
38
+ }
39
+ }
40
+ } catch (error) {
41
+ console.warn('[IpfsService] Failed to read custom gateway from localStorage:', error);
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Get all available gateways (custom first, then public list)
48
+ * @returns {string[]} Array of gateway base URLs
49
+ */
50
+ function getAllGateways() {
51
+ const custom = getCustomGateway();
52
+ return custom ? [custom, ...IPFS_GATEWAYS] : IPFS_GATEWAYS;
53
+ }
54
+
55
+ /**
56
+ * Check if a URI is an IPFS URI
57
+ * @param {string} uri - URI to check
58
+ * @returns {boolean} True if URI is IPFS
59
+ */
60
+ export function isIpfsUri(uri) {
61
+ if (!uri || typeof uri !== 'string') {
62
+ return false;
63
+ }
64
+ return uri.toLowerCase().startsWith('ipfs://');
65
+ }
66
+
67
+ /**
68
+ * Normalize IPFS path by removing ipfs:// prefix
69
+ * Handles both ipfs://CID and ipfs://CID/path formats
70
+ * @param {string} ipfsUri - IPFS URI (e.g., ipfs://Qm... or ipfs://Qm.../path)
71
+ * @returns {string} Normalized path (e.g., Qm... or Qm.../path)
72
+ */
73
+ export function normalizeIpfsPath(ipfsUri) {
74
+ if (!isIpfsUri(ipfsUri)) {
75
+ return ipfsUri;
76
+ }
77
+
78
+ // Remove ipfs:// prefix
79
+ const path = ipfsUri.substring(7); // 'ipfs://' is 7 characters
80
+
81
+ // Remove leading slash if present
82
+ return path.startsWith('/') ? path.substring(1) : path;
83
+ }
84
+
85
+ /**
86
+ * Resolve IPFS URI to HTTP URL using a specific gateway
87
+ * @param {string} ipfsUri - IPFS URI (e.g., ipfs://Qm...)
88
+ * @param {number} gatewayIndex - Index of gateway to use (0-based)
89
+ * @returns {string|null} HTTP URL or null if invalid
90
+ */
91
+ export function resolveIpfsToHttp(ipfsUri, gatewayIndex = 0) {
92
+ if (!isIpfsUri(ipfsUri)) {
93
+ return ipfsUri; // Return as-is if not IPFS
94
+ }
95
+
96
+ const gateways = getAllGateways();
97
+ if (gatewayIndex < 0 || gatewayIndex >= gateways.length) {
98
+ return null;
99
+ }
100
+
101
+ const normalizedPath = normalizeIpfsPath(ipfsUri);
102
+ const gateway = gateways[gatewayIndex];
103
+
104
+ return gateway + normalizedPath;
105
+ }
106
+
107
+ /**
108
+ * Fetch JSON from IPFS or HTTP URL with gateway rotation
109
+ * @param {string} urlOrIpfs - HTTP URL or IPFS URI
110
+ * @param {object} options - Fetch options (timeout, etc.)
111
+ * @returns {Promise<object>} Parsed JSON data
112
+ * @throws {Error} If all gateways fail or JSON is invalid
113
+ */
114
+ export async function fetchJsonWithIpfsSupport(urlOrIpfs, options = {}) {
115
+ const { timeout = 10000, ...fetchOptions } = options;
116
+
117
+ // If it's not an IPFS URI, use regular fetch
118
+ if (!isIpfsUri(urlOrIpfs)) {
119
+ const controller = new AbortController();
120
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
121
+
122
+ try {
123
+ const response = await fetch(urlOrIpfs, {
124
+ ...fetchOptions,
125
+ signal: controller.signal
126
+ });
127
+
128
+ if (!response.ok) {
129
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
130
+ }
131
+
132
+ const json = await response.json();
133
+ clearTimeout(timeoutId);
134
+ return json;
135
+ } catch (error) {
136
+ clearTimeout(timeoutId);
137
+ if (error.name === 'AbortError') {
138
+ throw new Error(`Request timeout after ${timeout}ms`);
139
+ }
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ // IPFS URI - try gateways in order
145
+ const gateways = getAllGateways();
146
+ const normalizedPath = normalizeIpfsPath(urlOrIpfs);
147
+ const errors = [];
148
+
149
+ for (let i = 0; i < gateways.length; i++) {
150
+ const gateway = gateways[i];
151
+ const httpUrl = gateway + normalizedPath;
152
+
153
+ const controller = new AbortController();
154
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
155
+
156
+ try {
157
+ const response = await fetch(httpUrl, {
158
+ ...fetchOptions,
159
+ signal: controller.signal
160
+ });
161
+
162
+ clearTimeout(timeoutId);
163
+
164
+ if (!response.ok) {
165
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
166
+ }
167
+
168
+ const json = await response.json();
169
+
170
+ // Success - log which gateway worked (for debugging)
171
+ if (i > 0) {
172
+ console.log(`[IpfsService] Resolved IPFS via gateway ${i + 1}/${gateways.length}: ${gateway}`);
173
+ }
174
+
175
+ return json;
176
+ } catch (error) {
177
+ clearTimeout(timeoutId);
178
+
179
+ const errorMsg = error.name === 'AbortError'
180
+ ? `Timeout after ${timeout}ms`
181
+ : error.message || 'Unknown error';
182
+
183
+ errors.push(`Gateway ${i + 1} (${gateway}): ${errorMsg}`);
184
+
185
+ // Continue to next gateway
186
+ continue;
187
+ }
188
+ }
189
+
190
+ // All gateways failed
191
+ throw new Error(
192
+ `Failed to fetch IPFS content after trying ${gateways.length} gateways:\n` +
193
+ errors.join('\n')
194
+ );
195
+ }
196
+
197
+ /**
198
+ * Test if an image URL can be loaded (for gateway rotation)
199
+ * @param {string} url - HTTP URL to test
200
+ * @param {number} timeout - Timeout in milliseconds
201
+ * @returns {Promise<boolean>} True if image loads successfully
202
+ */
203
+ export function testImageLoad(url, timeout = 5000) {
204
+ return new Promise((resolve) => {
205
+ const img = new Image();
206
+ const timeoutId = setTimeout(() => {
207
+ img.onload = null;
208
+ img.onerror = null;
209
+ resolve(false);
210
+ }, timeout);
211
+
212
+ img.onload = () => {
213
+ clearTimeout(timeoutId);
214
+ resolve(true);
215
+ };
216
+
217
+ img.onerror = () => {
218
+ clearTimeout(timeoutId);
219
+ resolve(false);
220
+ };
221
+
222
+ img.src = url;
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Get all available gateways (for UI display or debugging)
228
+ * @returns {string[]} Array of gateway base URLs
229
+ */
230
+ export function getAvailableGateways() {
231
+ return getAllGateways();
232
+ }
233
+
234
+ /**
235
+ * Set custom IPFS gateway (user preference)
236
+ * @param {string} gatewayBaseUrl - Base URL of custom gateway (e.g., 'https://mygateway.com/ipfs/')
237
+ */
238
+ export function setCustomGateway(gatewayBaseUrl) {
239
+ try {
240
+ if (gatewayBaseUrl) {
241
+ localStorage.setItem('customIpfsGatewayBaseUrl', gatewayBaseUrl);
242
+ } else {
243
+ localStorage.removeItem('customIpfsGatewayBaseUrl');
244
+ }
245
+ } catch (error) {
246
+ console.warn('[IpfsService] Failed to save custom gateway to localStorage:', error);
247
+ }
248
+ }
249
+