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;