/ src / utils / cryptoUtils.js
cryptoUtils.js
  1  import crypto from "crypto";
  2  import { ethers } from "ethers";
  3  import { Connection } from "@solana/web3.js";
  4  import { SuiClient } from "@mysten/sui/client";
  5  import config from "@/config.js";
  6  import { logger } from "@/middleware/logging.js";
  7  import cache, { CacheEvents } from "@/utils/cache.js";
  8  
  9  const BigIntUtils = {
 10    toBigInt: (value) => {
 11      if (typeof value === "bigint") {
 12        return value;
 13      } else if (typeof value === "string") {
 14        if (!/^-?\d+$/.test(value.trim())) {
 15          throw new Error(`Invalid string format for BigInt conversion: ${value}`);
 16        } else if (value.length > 100) {
 17          throw new Error(`String too large for BigInt conversion: ${value.length} characters`);
 18        }
 19  
 20        try {
 21          return BigInt(value);
 22        } catch (error) {
 23          throw new Error(`Cannot convert string to BigInt: ${error.message}`);
 24        }
 25      } else if (typeof value === "number") {
 26        if (!isFinite(value)) {
 27          throw new Error("Invalid numeric value");
 28        } else if (!Number.isInteger(value)) {
 29          throw new Error("Non-integer numbers cannot be converted to BigInt");
 30        } else if (Math.abs(value) > Number.MAX_SAFE_INTEGER) {
 31          throw new Error("Number exceeds safe integer range for BigInt conversion");
 32        }
 33        return BigInt(value);
 34      } else {
 35        throw new Error(`Cannot convert to BigInt: unsupported type ${typeof value}`);
 36      }
 37    },
 38  
 39    scaleAmount: (amount, decimals) => {
 40      const scaled = BigIntUtils.toBigInt(amount) * (10n ** BigInt(decimals));
 41      return scaled;
 42    },
 43  
 44    unscaleAmount: (scaledAmount, decimals) => {
 45      const divisor = 10n ** BigInt(decimals);
 46      const quotient = scaledAmount / divisor;
 47      const remainder = scaledAmount % divisor;
 48      return { quotient, remainder };
 49    },
 50  
 51    preciseMultiply: (amount, multiplier, decimals = 18) => {
 52      try {
 53        const scaledAmount = BigIntUtils.scaleAmount(amount, decimals);
 54        const result = (scaledAmount * BigIntUtils.toBigInt(multiplier)) / (10n ** BigInt(decimals));
 55        return result.toString();
 56      } catch (error) {
 57        throw new Error(`Multiplication failed: ${error.message}`);
 58      }
 59    },
 60  
 61    preciseAdd: (amount1, amount2, decimals = 18) => {
 62      try {
 63        const scaled1 = BigIntUtils.scaleAmount(amount1, decimals);
 64        const scaled2 = BigIntUtils.scaleAmount(amount2, decimals);
 65        const result = scaled1 + scaled2;
 66        return result.toString();
 67      } catch (error) {
 68        throw new Error(`Addition failed: ${error.message}`);
 69      }
 70    },
 71  
 72    preciseDivide: (numerator, denominator, decimals = 18) => {
 73      try {
 74        if (denominator === 0) {
 75          throw new Error("Division by zero");
 76        }
 77        const scaledNumerator = BigIntUtils.scaleAmount(numerator, decimals);
 78        const result = scaledNumerator / BigIntUtils.toBigInt(denominator);
 79        return result.toString();
 80      } catch (error) {
 81        throw new Error(`Division failed: ${error.message}`);
 82      }
 83    },
 84  
 85    formatTokenAmount: (scaledAmount, decimals, displayDecimals = 6) => {
 86      try {
 87        const { quotient, remainder } = BigIntUtils.unscaleAmount(BigIntUtils.toBigInt(scaledAmount), decimals);
 88  
 89        if (remainder === 0n) {
 90          return quotient.toString();
 91        }
 92  
 93        const remainderStr = remainder.toString().padStart(decimals, "0");
 94        const truncated = remainderStr.substring(0, displayDecimals);
 95        const result = `${quotient}.${truncated}`;
 96  
 97        return parseFloat(result).toString();
 98      } catch (error) {
 99        throw new Error(`Formatting failed: ${error.message}`);
100      }
101    },
102  };
103  
104  const ValidationUtils = {
105    validateWalletAddress: (address, type) => {
106      if (!address || typeof address !== "string") {
107        return false;
108      } else if (address.length > 128) {
109        return false;
110      }
111  
112      const patterns = {
113        phantom: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
114        solflare: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
115        brave: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
116        metamask: /^0x[a-fA-F0-9]{40}$/,
117        trust: /^0x[a-fA-F0-9]{40}$/,
118        rainbow: /^0x[a-fA-F0-9]{40}$/,
119        okx: /^0x[a-fA-F0-9]{40}$/,
120        solana: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
121        ethereum: /^0x[a-fA-F0-9]{40}$/,
122        polygon: /^0x[a-fA-F0-9]{40}$/,
123        arbitrum: /^0x[a-fA-F0-9]{40}$/,
124        optimism: /^0x[a-fA-F0-9]{40}$/,
125        base: /^0x[a-fA-F0-9]{40}$/,
126        evm: /^0x[a-fA-F0-9]{40}$/,
127        sui: /^0x[a-fA-F0-9]{64}$/,
128        bitcoin: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/,
129      };
130  
131      const pattern = patterns[type];
132      return pattern ? pattern.test(address) : false;
133    },
134  
135    validateTxHash: (hash, chain) => {
136      if (!hash || typeof hash !== "string") {
137        return false;
138      } else if (hash.length > 128) {
139        return false;
140      }
141  
142      const patterns = {
143        solana: /^[1-9A-HJ-NP-Za-km-z]{87,88}$/,
144        ethereum: /^0x[a-fA-F0-9]{64}$/,
145        polygon: /^0x[a-fA-F0-9]{64}$/,
146        arbitrum: /^0x[a-fA-F0-9]{64}$/,
147        optimism: /^0x[a-fA-F0-9]{64}$/,
148        base: /^0x[a-fA-F0-9]{64}$/,
149        sui: /^[A-Za-z0-9+/=]{43,44}$|^0x[a-fA-F0-9]{64}$/,
150      };
151  
152      const pattern = patterns[chain];
153      return pattern ? pattern.test(hash) : false;
154    },
155  
156    validateAmount: (amount) => {
157      if (!amount) {
158        return false;
159      } else if (isNaN(parseFloat(amount)) || parseFloat(amount) < 0 || !isFinite(parseFloat(amount))) {
160        return false;
161      } else {
162        return true;
163      }
164    },
165  };
166  
167  const SecurityUtils = {
168    normalizePrivateKey: (privateKey) => {
169      if (!privateKey || typeof privateKey !== "string") {
170        throw new Error("Invalid private key format");
171      }
172      if (privateKey.length < 32 || privateKey.length > 128) {
173        throw new Error("Invalid private key length");
174      }
175      if ((privateKey.startsWith("\"") && privateKey.endsWith("\"")) ||
176        (privateKey.startsWith("'") && privateKey.endsWith("'"))) {
177        return privateKey.slice(1, -1).trim();
178      }
179      return privateKey.trim();
180    },
181  
182    timingSafeCompare: (a, b) => {
183      if (typeof a !== "string" || typeof b !== "string") {
184        return false;
185      } else {
186        const bufA = Buffer.from(a);
187        const bufB = Buffer.from(b);
188        if (bufA.length !== bufB.length) {
189          crypto.timingSafeEqual(bufA, bufA);
190          return false;
191        } else {
192          return crypto.timingSafeEqual(bufA, bufB);
193        }
194      }
195    },
196  
197    addTimingNoise: async (minMs = 50, maxMs = 150) => {
198      const delay = crypto.randomInt(minMs, maxMs);
199      await new Promise(resolve => setTimeout(resolve, delay));
200    },
201  };
202  
203  const roundUpGasFee = (value) => {
204    if (!value || !isFinite(value)) {
205      return 0;
206    }
207  
208    try {
209      const valueStr = value.toString();
210      const decimalIndex = valueStr.indexOf(".");
211  
212      if (decimalIndex === -1) {
213        return parseFloat(valueStr);
214      }
215  
216      const decimalPart = valueStr.substring(decimalIndex + 1);
217      let firstNonZeroIndex = -1;
218  
219      for (let i = 0; i < decimalPart.length; i++) {
220        if (decimalPart[i] !== "0") {
221          firstNonZeroIndex = i;
222          break;
223        }
224      }
225  
226      if (firstNonZeroIndex === -1) {
227        return 0;
228      }
229  
230      const result = 10 ** -firstNonZeroIndex;
231      return result;
232    } catch {
233      return 0;
234    }
235  };
236  
237  const providerCache = new Map();
238  
239  const RpcUtils = {
240    getRpcClient: (networkId, networkType = "evm") => {
241      const cacheKey = `${networkId}-${networkType}`;
242  
243      if (providerCache.has(cacheKey)) {
244        return providerCache.get(cacheKey);
245      }
246  
247      if (!config.rpc[networkId]) {
248        throw new Error(`RPC configuration not found for network: ${networkId}`);
249      }
250  
251      const rpcUrl = config.rpc[networkId].mainnet;
252      if (!rpcUrl) {
253        throw new Error(`RPC URL not configured for network: ${networkId}`);
254      }
255  
256      if (!rpcUrl.startsWith("https://") && !rpcUrl.startsWith("wss://")) {
257        throw new Error(`Insecure RPC protocol for network: ${networkId}`);
258      }
259  
260      let client;
261  
262      switch (networkType) {
263      case "evm":
264        const chainId = getNetworkChainId(networkId);
265        if (!chainId) {
266          throw new Error(`Unknown chain ID for network: ${networkId}`);
267        }
268        client = new ethers.JsonRpcProvider(rpcUrl, {
269          name: networkId,
270          chainId,
271          batchMaxCount: config.limits.rpc.batchMaxCount,
272          pollingInterval: config.limits.rpc.pollingInterval,
273        });
274        break;
275      case "solana":
276        client = new Connection(rpcUrl, {
277          commitment: "confirmed",
278          confirmTransactionInitialTimeout: config.timeouts.rpc.solana,
279        });
280        break;
281      case "sui":
282        client = new SuiClient({ url: rpcUrl });
283        break;
284      default:
285        throw new Error(`Unsupported network type: ${networkType}`);
286      }
287  
288      if (providerCache.size >= config.limits.rpc.providerCache) {
289        const firstKey = providerCache.keys().next().value;
290        providerCache.delete(firstKey);
291      }
292  
293      providerCache.set(cacheKey, client);
294      return client;
295    },
296  
297    clearRpcCache: () => {
298      providerCache.clear();
299      cache.clear("rpc");
300      cache.clear("price");
301      cache.clear("block");
302  
303      cache.emit(CacheEvents.CACHE_CLEARED, {
304        type: "rpc",
305        timestamp: new Date(),
306      });
307  
308      logger.info("RPC cache cleared due to network change or security event");
309    },
310  
311    getCachedRpcCall: async (key, fetchFn, ttl = 30) => {
312      if (!key || typeof key !== "string") {
313        throw new Error("Invalid cache key provided");
314      } else if (typeof fetchFn !== "function") {
315        throw new Error("Invalid fetch function provided");
316      } else if (typeof ttl !== "number" || ttl <= 0 || ttl > config.limits.rpc.cacheTtlMax) {
317        throw new Error("Invalid TTL provided");
318      }
319  
320      const cached = cache.get("rpc", key);
321      if (cached !== undefined) {
322        const age = Date.now() - (cached.timestamp || 0);
323        if (age < ttl * 1000) {
324          if (cached.data === null || cached.data === undefined) {
325            cache.delete("rpc", key);
326            throw new Error("Invalid cached data detected");
327          }
328          if (typeof cached.data === "object" && cached.data !== null) {
329            if (Object.keys(cached.data).length === 0) {
330              cache.delete("rpc", key);
331              throw new Error("Empty cached object detected");
332            }
333          }
334          return cached.data || cached;
335        }
336      }
337  
338      try {
339        const result = await fetchFn();
340        if (result === null || result === undefined) {
341          throw new Error("RPC call returned invalid result");
342        }
343        if (typeof result === "object" && result !== null) {
344          if (Array.isArray(result) && result.length === 0) {
345            throw new Error("RPC call returned empty array");
346          }
347        }
348  
349        const cacheEntry = {
350          data: result,
351          timestamp: Date.now(),
352          key,
353          checksum: crypto.createHash("sha256").update(JSON.stringify(result)).digest("hex").slice(0, 16),
354        };
355        cache.set("rpc", key, cacheEntry, ttl * 1000);
356        return result;
357      } catch (error) {
358        logger.error(`RPC call failed for ${key}:`, error);
359        cache.delete("rpc", key);
360        throw error;
361      }
362    },
363  
364    getCachedPrice: async (symbol, fetchFn) => {
365      const cached = cache.get("price", symbol);
366      if (cached !== undefined) {
367        const age = Date.now() - (cached.timestamp || 0);
368        if (age < 60000) {
369          return cached.data || cached;
370        }
371      }
372  
373      try {
374        const price = await fetchFn();
375        const cacheEntry = {
376          data: price,
377          timestamp: Date.now(),
378          symbol,
379        };
380        cache.set("price", symbol, cacheEntry, 60000);
381  
382        cache.emit(CacheEvents.PRICE_FETCHED, {
383          symbol,
384          price,
385          timestamp: new Date(),
386        });
387  
388        return price;
389      } catch (error) {
390        logger.error(`Price fetch failed for ${symbol}:`, error);
391        cache.delete("price", symbol);
392  
393        cache.emit(CacheEvents.PRICE_FAILED, {
394          symbol,
395          error: error.message,
396          timestamp: new Date(),
397        });
398  
399        throw error;
400      }
401    },
402  };
403  
404  cache.on(CacheEvents.NETWORK_CHANGED, () => {
405    RpcUtils.clearRpcCache();
406  });
407  
408  cache.on(CacheEvents.USER_BANNED, (data) => {
409    if (data.clearAll) {
410      RpcUtils.clearRpcCache();
411    }
412  });
413  
414  cache.on(CacheEvents.RATE_UPDATED, () => {
415    cache.clear("price");
416  });
417  
418  cache.on(CacheEvents.BLOCK_RECEIVED, (data) => {
419    if (data.network) {
420      cache.invalidateByPattern("block", `${data.network}:*`);
421    }
422  });
423  
424  const getNetworkChainId = (networkId) => {
425    const chainIds = {
426      ethereum: 1,
427      arbitrum: 42161,
428      polygon: 137,
429      base: 8453,
430      bsc: 56,
431      avalanche: 43114,
432      optimism: 10,
433    };
434    return chainIds[networkId] || null;
435  };
436  
437  const handleError = (error, context) => {
438    const errorMessage = error instanceof Error ? error.message : String(error);
439    const errorInfo = {
440      code: error.code || "UNKNOWN_ERROR",
441      message: errorMessage,
442      context,
443      timestamp: new Date(),
444    };
445  
446    logger.error(`Error in ${context}:`, errorInfo);
447  
448    cache.emit(CacheEvents.ERROR, errorInfo);
449  
450    throw errorInfo;
451  };
452  
453  export { BigIntUtils, ValidationUtils, SecurityUtils, RpcUtils, roundUpGasFee, handleError };
454  
455  export const toBigInt = BigIntUtils.toBigInt;
456  export const scaleAmount = BigIntUtils.scaleAmount;
457  export const unscaleAmount = BigIntUtils.unscaleAmount;
458  export const preciseMultiply = BigIntUtils.preciseMultiply;
459  export const preciseAdd = BigIntUtils.preciseAdd;
460  export const preciseDivide = BigIntUtils.preciseDivide;
461  export const formatTokenAmount = BigIntUtils.formatTokenAmount;
462  export const validateWalletAddress = ValidationUtils.validateWalletAddress;
463  export const validateTxHash = ValidationUtils.validateTxHash;
464  export const validateAmount = ValidationUtils.validateAmount;
465  export const normalizePrivateKey = SecurityUtils.normalizePrivateKey;
466  export const timingSafeCompare = SecurityUtils.timingSafeCompare;
467  export const addTimingNoise = SecurityUtils.addTimingNoise;
468  export const getRpcClient = RpcUtils.getRpcClient;
469  export const clearRpcCache = RpcUtils.clearRpcCache;
470  export const getCachedRpcCall = RpcUtils.getCachedRpcCall;
471  export const getCachedPrice = RpcUtils.getCachedPrice;