UNPKG

uniswapx-analyzer-mcp

Version:

Model Context Protocol server for UniswapX trading analytics and metrics with multi-chain support (base, ethereum) and cloud deployment

1,299 lines (1,291 loc) 134 kB
// build-temp/server.js import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; // build-temp/GoogleDriveApiClient.js import { google } from "googleapis"; import fs from "fs"; import path from "path"; import { createWriteStream } from "fs"; import { pipeline } from "stream/promises"; import { createGunzip } from "zlib"; import os from "os"; var GoogleDriveApiClient = class { constructor(chain = "base", reactorType = "priority-orders") { this.chain = chain; this.reactorType = reactorType; this.folderId = process.env.GOOGLE_DRIVE_FOLDER_ID; this.apiKey = process.env.GOOGLE_API_KEY; this.cacheDir = path.join(os.tmpdir(), "uniswapx-cache", `${chain}-${reactorType}`); this.drive = null; this.validateEnvironment(); this.ensureCacheDir(); this.initializeDrive(); } validateEnvironment() { if (!this.folderId) { throw new Error("GOOGLE_DRIVE_FOLDER_ID environment variable is required"); } if (!this.apiKey) { throw new Error("GOOGLE_API_KEY environment variable is required"); } } ensureCacheDir() { if (!fs.existsSync(this.cacheDir)) { fs.mkdirSync(this.cacheDir, { recursive: true }); } } initializeDrive() { this.drive = google.drive({ version: "v3", auth: this.apiKey }); } /** * List files in the shared Google Drive folder * Searches in {chain}-{reactorType} subfolder * @param {string} namePattern - Optional pattern to filter files * @returns {Promise<Array>} List of files */ async listFiles(namePattern = null) { try { const subFolderName = `${this.chain}-${this.reactorType}`; const subFolderQuery = `name='${subFolderName}' and '${this.folderId}' in parents and mimeType='application/vnd.google-apps.folder' and trashed=false`; const subFolderResponse = await this.drive.files.list({ q: subFolderQuery, fields: "files(id,name)" }); if (!subFolderResponse.data.files || subFolderResponse.data.files.length === 0) { throw new Error(`Subfolder not found in Google Drive: ${subFolderName}`); } const subFolderId = subFolderResponse.data.files[0].id; console.error(`\u2705 Found subfolder: ${subFolderName} (ID: ${subFolderId})`); const query = `'${subFolderId}' in parents and trashed=false`; const response = await this.drive.files.list({ q: query, fields: "files(id,name,modifiedTime,size)", orderBy: "name desc" }); let files = response.data.files || []; if (namePattern) { const regex = new RegExp(namePattern); files = files.filter((file) => regex.test(file.name)); } return files; } catch (error) { throw new Error(`Failed to list Google Drive files: ${error.message}`); } } /** * Download a file from Google Drive * @param {string} fileId - Google Drive file ID * @param {string} fileName - Local file name * @param {boolean} isPrefetched - Whether this is a prefetched file (longer cache TTL) * @returns {Promise<string>} Path to downloaded file */ async downloadFile(fileId, fileName, isPrefetched = false) { try { const localPath = path.join(this.cacheDir, fileName); const maxAge = isPrefetched ? 864e5 : 36e5; if (fs.existsSync(localPath)) { const stats = fs.statSync(localPath); const age = Date.now() - stats.mtime.getTime(); if (age < maxAge) { console.error(`Using cached file: ${fileName} (age: ${Math.round(age / 36e5)}h)`); return localPath; } } console.error(`Downloading from Google Drive: ${fileName}`); const response = await this.drive.files.get({ fileId, alt: "media" }, { responseType: "stream" }); const writeStream = createWriteStream(localPath); await pipeline(response.data, writeStream); return localPath; } catch (error) { throw new Error(`Failed to download file ${fileName}: ${error.message}`); } } /** * Get data for a specific date (only .jsonl.gz files) * @param {string|Date} date - Date in YYYY-MM-DD format or Date object * @returns {Promise<Array>} Parsed JSON data */ async getDataForDate(date) { const dateStr = date instanceof Date ? date.toISOString().split("T")[0] : date; try { const fileName = `${dateStr}.jsonl.gz`; const files = await this.listFiles(`^${fileName.replace(".", "\\.")}$`); if (files.length > 0) { const file = files[0]; const localPath = await this.downloadFile(file.id, fileName); return this.parseDataFile(localPath, true); } throw new Error(`No data file found for date: ${dateStr}`); } catch (error) { throw new Error(`Failed to get data for date ${dateStr}: ${error.message}`); } } /** * Parse a data file (compressed or uncompressed) * @param {string} filePath - Path to the file * @param {boolean} isCompressed - Whether the file is gzipped * @returns {Promise<Array>} Parsed JSON data */ async parseDataFile(filePath, isCompressed = false) { try { let content; if (isCompressed) { const fileStream = fs.createReadStream(filePath); const gunzip = createGunzip(); const chunks = []; await pipeline( fileStream, gunzip, async function* (source) { for await (const chunk of source) { chunks.push(chunk); } } ); content = Buffer.concat(chunks).toString("utf8"); } else { content = fs.readFileSync(filePath, "utf8"); } return content.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)); } catch (error) { throw new Error(`Failed to parse data file ${filePath}: ${error.message}`); } } /** * Get data for a date range * @param {string|Date} startDate - Start date * @param {string|Date} endDate - End date * @returns {Promise<Array>} Combined data from all dates */ async getDataForDateRange(startDate, endDate) { const start = startDate instanceof Date ? startDate : new Date(startDate); const end = endDate instanceof Date ? endDate : new Date(endDate); if (start > end) { throw new Error("Start date cannot be after end date"); } const dates = []; const current = new Date(start); while (current <= end) { dates.push(current.toISOString().split("T")[0]); current.setDate(current.getDate() + 1); } console.error(`\u{1F680} Parallel download starting for ${dates.length} files...`); const allData = []; const missingDates = []; const BATCH_SIZE = 6; for (let i = 0; i < dates.length; i += BATCH_SIZE) { const batch = dates.slice(i, i + BATCH_SIZE); console.error(`\u{1F4E6} Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(dates.length / BATCH_SIZE)}: ${batch[0]} to ${batch[batch.length - 1]}`); const batchPromises = batch.map(async (dateStr) => { try { const dateData = await this.getDataForDate(dateStr); return { dateStr, data: dateData, success: true }; } catch (error) { console.warn(`\u26A0\uFE0F No data found for ${dateStr}: ${error.message}`); return { dateStr, data: null, success: false, error: error.message }; } }); const batchResults = await Promise.allSettled(batchPromises); batchResults.forEach((result, index) => { if (result.status === "fulfilled") { const { dateStr, data, success } = result.value; if (success && data) { allData.push(...data); } else { missingDates.push(dateStr); } } else { console.error(`\u274C Batch download failed for ${batch[index]}: ${result.reason}`); missingDates.push(batch[index]); } }); } console.error(`\u2705 Parallel download completed: ${allData.length} orders from ${dates.length - missingDates.length}/${dates.length} files`); if (missingDates.length > 0) { console.warn(`\u26A0\uFE0F Missing data for dates: ${missingDates.join(", ")}`); } return allData; } /** * Check if data exists for a specific date (only .jsonl.gz files) * @param {string|Date} date - Date to check * @returns {Promise<boolean>} Whether data exists */ async hasDataForDate(date) { const dateStr = date instanceof Date ? date.toISOString().split("T")[0] : date; try { const fileName = `${dateStr}.jsonl.gz`; const files = await this.listFiles(`^${fileName.replace(".", "\\.")}$`); return files.length > 0; } catch (error) { console.warn(`Error checking data for ${dateStr}: ${error.message}`); return false; } } /** * Pre-fetch recent data files for better performance * @param {number} days - Number of recent days to pre-fetch (default: 7) * @returns {Promise<Object>} Statistics about the pre-fetch operation */ async preFetchRecentData(days = 7) { console.error(`\u{1F504} Pre-fetching past ${days} days of data...`); const stats = { attempted: 0, downloaded: 0, cached: 0, errors: 0, errorDates: [] }; const endDate = /* @__PURE__ */ new Date(); endDate.setDate(endDate.getDate() - 1); for (let i = 0; i < days; i++) { const currentDate = new Date(endDate); currentDate.setDate(currentDate.getDate() - i); const dateStr = currentDate.toISOString().split("T")[0]; stats.attempted++; try { const fileName = `${dateStr}.jsonl.gz`; const files = await this.listFiles(`^${fileName.replace(".", "\\.")}$`); if (files.length > 0) { const file = files[0]; const localPath = path.join(this.cacheDir, fileName); if (fs.existsSync(localPath)) { const stats_file = fs.statSync(localPath); const age = Date.now() - stats_file.mtime.getTime(); if (age < 864e5) { console.error(` \u2713 ${dateStr} (already cached)`); stats.cached++; continue; } } await this.downloadFile(file.id, fileName, true); console.error(` \u2B07\uFE0F ${dateStr} (downloaded)`); stats.downloaded++; } else { console.warn(` \u26A0\uFE0F ${dateStr} (no data file found)`); stats.errors++; stats.errorDates.push(dateStr); } } catch (error) { console.error(` \u274C ${dateStr} (error: ${error.message})`); stats.errors++; stats.errorDates.push(dateStr); } if (i < days - 1) { await new Promise((resolve) => setTimeout(resolve, 100)); } } console.error(`\u2705 Pre-fetch completed: ${stats.downloaded} downloaded, ${stats.cached} cached, ${stats.errors} errors`); return stats; } /** * Clean up old cached files using hybrid logic * - .jsonl.gz files: use data date from filename (maxAgeDays) * - Other files: use fetch time (1 hour) * @param {number} maxAgeDays - Maximum age in days for data files (default: 30) * @returns {Promise<Object>} Statistics about the cleanup operation */ async cleanupOldCache(maxAgeDays = 30) { console.error(`\u{1F9F9} Cleaning up cache with hybrid logic:`); console.error(` \u{1F4C5} Data files (.jsonl.gz): ${maxAgeDays} days by data date`); console.error(` \u{1F550} Other files: 1 hour by fetch time`); const stats = { total: 0, removed: 0, kept: 0, errors: 0, dataFiles: { total: 0, removed: 0, kept: 0 }, otherFiles: { total: 0, removed: 0, kept: 0 } }; try { if (!fs.existsSync(this.cacheDir)) { console.error(" \u2139\uFE0F Cache directory does not exist"); return stats; } const files = fs.readdirSync(this.cacheDir); const today = /* @__PURE__ */ new Date(); const dataDateCutoff = new Date(today); dataDateCutoff.setDate(dataDateCutoff.getDate() - maxAgeDays); const fetchTimeCutoff = Date.now() - 1 * 60 * 60 * 1e3; for (const file of files) { stats.total++; const filePath = path.join(this.cacheDir, file); try { const dataFileMatch = file.match(/^(\d{4}-\d{2}-\d{2})\.jsonl\.gz$/); if (dataFileMatch) { stats.dataFiles.total++; const dataDateStr = dataFileMatch[1]; const dataDate = new Date(dataDateStr); if (dataDate < dataDateCutoff) { fs.unlinkSync(filePath); const daysOld = Math.floor((today - dataDate) / (24 * 60 * 60 * 1e3)); console.error(` \u{1F5D1}\uFE0F Removed: ${file} (data date: ${daysOld} days old)`); stats.removed++; stats.dataFiles.removed++; } else { const daysOld = Math.floor((today - dataDate) / (24 * 60 * 60 * 1e3)); console.error(` \u2705 Kept: ${file} (data date: ${daysOld} days old)`); stats.kept++; stats.dataFiles.kept++; } } else { stats.otherFiles.total++; const fileStat = fs.statSync(filePath); if (fileStat.mtime.getTime() < fetchTimeCutoff) { fs.unlinkSync(filePath); const hoursOld = Math.round((Date.now() - fileStat.mtime.getTime()) / (60 * 60 * 1e3)); console.error(` \u{1F5D1}\uFE0F Removed: ${file} (fetch time: ${hoursOld}h old)`); stats.removed++; stats.otherFiles.removed++; } else { const hoursOld = Math.round((Date.now() - fileStat.mtime.getTime()) / (60 * 60 * 1e3)); console.error(` \u2705 Kept: ${file} (fetch time: ${hoursOld}h old)`); stats.kept++; stats.otherFiles.kept++; } } } catch (error) { console.warn(` \u26A0\uFE0F Error processing ${file}: ${error.message}`); stats.errors++; } } console.error(`\u2705 Cleanup completed:`); console.error(` \u{1F4CA} Total: ${stats.removed} removed, ${stats.kept} kept, ${stats.errors} errors`); console.error(` \u{1F4C5} Data files: ${stats.dataFiles.removed} removed, ${stats.dataFiles.kept} kept`); console.error(` \u{1F4C1} Other files: ${stats.otherFiles.removed} removed, ${stats.otherFiles.kept} kept`); } catch (error) { console.error(`\u274C Cache cleanup failed: ${error.message}`); stats.errors++; } return stats; } /** * Get cache status and statistics * @returns {Promise<Object>} Cache status information */ async getCacheStatus() { const status = { cacheDir: this.cacheDir, exists: fs.existsSync(this.cacheDir), files: [], totalSize: 0, oldestFile: null, newestFile: null }; try { if (status.exists) { const files = fs.readdirSync(this.cacheDir); for (const file of files) { const filePath = path.join(this.cacheDir, file); const stats = fs.statSync(filePath); const fileInfo = { name: file, size: stats.size, modified: stats.mtime.toISOString(), age: Date.now() - stats.mtime.getTime() }; status.files.push(fileInfo); status.totalSize += stats.size; if (!status.oldestFile || stats.mtime < new Date(status.oldestFile.modified)) { status.oldestFile = fileInfo; } if (!status.newestFile || stats.mtime > new Date(status.newestFile.modified)) { status.newestFile = fileInfo; } } status.files.sort((a, b) => new Date(b.modified) - new Date(a.modified)); } } catch (error) { status.error = error.message; } return status; } /** * Clear local cache */ clearCache() { try { if (fs.existsSync(this.cacheDir)) { const files = fs.readdirSync(this.cacheDir); for (const file of files) { fs.unlinkSync(path.join(this.cacheDir, file)); } console.error("Cache cleared"); } } catch (error) { console.warn(`Failed to clear cache: ${error.message}`); } } }; // build-temp/utils/TokenInfoManager.js import fs3 from "fs/promises"; import path3 from "path"; import { ethers } from "ethers"; // build-temp/utils/PathManager.js import path2 from "path"; import fs2 from "fs/promises"; var PathManager = class { /** * Get the base data directory path for a chain * @param {string} chainName - Chain name (e.g., "ethereum", "base") * @returns {string} Chain data directory path */ static getChainDataPath(chainName) { const normalizedName = chainName.toLowerCase(); return path2.join("data", normalizedName); } /** * Get the sync directory path for a specific reactor on a chain * @param {string} chainName - Chain name * @param {string} reactorType - Reactor type (e.g., "PriorityOrderReactor") * @returns {string} Reactor sync directory path */ static getReactorSyncPath(chainName, reactorType) { const orderDir = this.getOrderDirectory(reactorType); return path2.join(this.getChainDataPath(chainName), orderDir); } /** * Map reactor type to directory name * @param {string} reactorType - Reactor type * @returns {string} Directory name for the reactor type */ static getOrderDirectory(reactorType) { const dirMap = { PriorityOrderReactor: "priority-orders", V2DutchOrderReactor: "dutch-orders", ExclusiveDutchOrderReactor: "limit-orders" // Exclusive Dutch handles "limit orders" }; return dirMap[reactorType] || "generic-orders"; } /** * Get the processed orders cache directory for a chain * @param {string} chainName - Chain name * @returns {string} Orders cache directory path */ static getOrdersCachePath(chainName) { return path2.join(this.getChainDataPath(chainName), "orders"); } /** * Get the token cache file path for a chain * @param {string} chainName - Chain name * @returns {string} Token cache file path */ static getTokenCachePath(chainName) { return path2.join(this.getChainDataPath(chainName), "token-cache.json"); } /** * Get the token price cache file path for a chain * @param {string} chainName - Chain name * @returns {string} Token price cache file path */ static getTokenPriceCachePath(chainName) { return path2.join(this.getChainDataPath(chainName), "token-price-cache.json"); } /** * Get high-profit tracking file path for a chain * @param {string} chainName - Chain name * @returns {string} High-profit tracking file path */ static getHighProfitTrackingPath(chainName) { return path2.join(this.getChainDataPath(chainName), "high-profit-last-position.json"); } /** * Get daily sync file path for a specific reactor * @param {string} chainName - Chain name * @param {string} reactorType - Reactor type * @param {string} dateString - Date in YYYY-MM-DD format * @returns {string} Daily sync file path */ static getDailySyncFilePath(chainName, reactorType, dateString) { const syncDir = this.getReactorSyncPath(chainName, reactorType); return path2.join(syncDir, `${dateString}.jsonl`); } /** * Get compressed daily sync file path * @param {string} chainName - Chain name * @param {string} reactorType - Reactor type * @param {string} dateString - Date in YYYY-MM-DD format * @returns {string} Compressed daily sync file path */ static getCompressedSyncFilePath(chainName, reactorType, dateString) { return `${this.getDailySyncFilePath(chainName, reactorType, dateString)}.gz`; } /** * Get Google Drive remote path for a chain and reactor * @param {string} chainName - Chain name (e.g., "ethereum", "base") * @param {string} reactorType - Reactor type (e.g., "PriorityOrderReactor") * @returns {string} Google Drive remote path */ static getGoogleDriveRemotePath(chainName, reactorType) { const basePath = "UniswapX-Historical-Data"; if (!chainName || !reactorType) { return basePath; } const orderDir = this.getOrderDirectory(reactorType); return `${basePath}/${chainName}-${orderDir}`; } /** * Get shared data directory path * @returns {string} Shared data directory path */ static getSharedDataPath() { return path2.join("data", "shared"); } /** * Get migration log file path * @returns {string} Migration log file path */ static getMigrationLogPath() { return path2.join(this.getSharedDataPath(), "migration-log.json"); } /** * Get report output directory path * @param {string} [chainName] - Optional chain name for chain-specific reports * @returns {string} Report output directory path */ static getReportOutputPath(chainName = null) { if (chainName) { return path2.join("reports", chainName.toLowerCase()); } return "reports"; } /** * Get backup directory path for data migration * @returns {string} Backup directory path */ static getBackupPath() { const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:-]/g, ""); return path2.join("data_backup", `backup_${timestamp}`); } /** * Legacy path mapping for backward compatibility */ static getLegacyPaths() { return { sync: path2.join("data", "sync"), orders: path2.join("data", "orders"), tokenCache: path2.join("data", "token-cache.json"), tokenPriceCache: path2.join("data", "token-price-cache.json"), highProfitTracking: path2.join("data", "high-profit-last-position.json") }; } /** * Create directory structure for a chain * @param {string} chainName - Chain name * @param {string[]} reactorTypes - Array of reactor types for this chain * @returns {Promise<void>} */ static async createChainDirectories(chainName, reactorTypes = []) { const chainPath = this.getChainDataPath(chainName); const ordersPath = this.getOrdersCachePath(chainName); await fs2.mkdir(chainPath, { recursive: true }); await fs2.mkdir(ordersPath, { recursive: true }); for (const reactorType of reactorTypes) { const syncPath = this.getReactorSyncPath(chainName, reactorType); await fs2.mkdir(syncPath, { recursive: true }); } console.log(`\u2705 Created directory structure for chain: ${chainName}`); } /** * Create shared data directories * @returns {Promise<void>} */ static async createSharedDirectories() { const sharedPath = this.getSharedDataPath(); await fs2.mkdir(sharedPath, { recursive: true }); console.log("\u2705 Created shared data directory"); } /** * Validate that a path exists and is accessible * @param {string} filePath - Path to validate * @returns {Promise<boolean>} True if path exists and is accessible */ static async validatePath(filePath) { try { await fs2.access(filePath); return true; } catch { return false; } } /** * Get all reactor sync directories for a chain * @param {string} chainName - Chain name * @returns {Promise<string[]>} Array of reactor sync directory paths */ static async getExistingReactorDirectories(chainName) { const syncBasePath = path2.join(this.getChainDataPath(chainName), "sync"); try { const entries = await fs2.readdir(syncBasePath, { withFileTypes: true }); return entries.filter((entry) => entry.isDirectory()).map((entry) => path2.join(syncBasePath, entry.name)); } catch { return []; } } /** * Get file pattern for globbing sync files * @param {string} chainName - Chain name * @param {string} reactorType - Reactor type * @param {string} pattern - File pattern (e.g., "*.jsonl", "2025-*.jsonl.gz") * @returns {string} Glob pattern for sync files */ static getSyncFilePattern(chainName, reactorType, pattern = "*.jsonl") { const syncPath = this.getReactorSyncPath(chainName, reactorType); return path2.join(syncPath, pattern); } /** * Convert legacy path to new multi-chain path * @param {string} legacyPath - Legacy file path * @param {string} chainName - Target chain name (default: "base") * @param {string} reactorType - Reactor type (default: "PriorityOrderReactor") * @returns {string} New multi-chain path */ static convertLegacyPath(legacyPath, chainName = "base", reactorType = "PriorityOrderReactor") { const normalized = path2.normalize(legacyPath); if (normalized.includes(path2.join("data", "sync"))) { const fileName = path2.basename(normalized); const dateString = fileName.replace(".jsonl", ""); const syncDir = this.getReactorSyncPath(chainName, reactorType); return path2.join(syncDir, dateString); } if (normalized.endsWith("token-cache.json")) { return this.getTokenCachePath(chainName); } if (normalized.endsWith("token-price-cache.json")) { return this.getTokenPriceCachePath(chainName); } if (normalized.endsWith("high-profit-last-position.json")) { return this.getHighProfitTrackingPath(chainName); } if (normalized.includes(path2.join("data", "orders"))) { const relativePath = path2.relative(path2.join("data", "orders"), normalized); return path2.join(this.getOrdersCachePath(chainName), relativePath); } return legacyPath; } /** * Get configuration for .gitignore entries * @returns {string[]} Array of gitignore patterns for multi-chain structure */ static getGitignorePatterns() { return [ "# Multi-chain data directories", "data/ethereum/", "data/base/", "data/unichain/", "data/*/token-cache.json", "data/*/token-price-cache.json", "data/*/high-profit-last-position.json", "data/shared/migration-log.json", "data_backup/", "", "# Keep example and config files", "!data/example/", "!*.example.*" ]; } }; // build-temp/utils/TokenInfoManager.js var ERC20_ABI = [ "function symbol() view returns (string)", "function decimals() view returns (uint8)", "function name() view returns (string)" ]; var TokenInfoManager = class { constructor(options = {}) { this.chainName = options.chainName || "base"; const rpcUrls = { ethereum: process.env.ETHEREUM_RPC_URL, base: process.env.BASE_RPC_URL }; this.rpcUrl = rpcUrls[this.chainName.toLowerCase()] || process.env.BASE_RPC_URL || "https://mainnet.base.org"; this.enableOnChainFetch = options.enableOnChainFetch || false; this.provider = this.enableOnChainFetch ? new ethers.JsonRpcProvider(this.rpcUrl) : null; this.cache = /* @__PURE__ */ new Map(); this.initialized = false; this.cachePath = options.cachePath || this.getEnvironmentAwareCachePath(); } /** * Get environment-aware cache path with chain support * @returns {string} Cache file path */ getEnvironmentAwareCachePath() { if (process.env.TOKEN_CACHE_PATH) { return process.env.TOKEN_CACHE_PATH; } const isCloudEnvironment = !!(process.env.PORT || process.env.NODE_ENV === "production" || process.env.GOOGLE_CLOUD_PROJECT || process.env.K_SERVICE); if (isCloudEnvironment) { return `/tmp/token-cache-${this.chainName}.json`; } else { return PathManager.getTokenCachePath(this.chainName); } } /** * 初始化管理器,載入緩存文件 */ async initialize() { if (this.initialized) return true; try { await fs3.mkdir(path3.dirname(this.cachePath), { recursive: true }); const cacheData = await fs3.readFile(this.cachePath, "utf8"); const cacheObj = JSON.parse(cacheData); Object.entries(cacheObj).forEach(([address, tokenInfo]) => { const normalizedAddress = address.toLowerCase(); this.cache.set(normalizedAddress, tokenInfo); }); console.log( `\u{1F4E6} \u8F09\u5165\u4EE3\u5E63\u7DE9\u5B58: ${this.cache.size} \u500B\u4EE3\u5E63 (${this.chainName} \u93C8)` ); } catch (error) { console.log(`\u{1F4E6} \u5275\u5EFA\u65B0\u7684\u4EE3\u5E63\u7DE9\u5B58\u6587\u4EF6 (${this.chainName} \u93C8) (${error.message})`); if (error.code === "EACCES" || error.code === "ENOENT") { console.log(`\u26A0\uFE0F \u8B66\u544A: \u7121\u6CD5\u5275\u5EFA\u7DE9\u5B58\u76EE\u9304\uFF0C\u4F7F\u7528\u50C5\u5167\u5B58\u7DE9\u5B58\u6A21\u5F0F`); } } this.initialized = true; return true; } /** * 保存緩存到文件 */ async saveCache() { try { const cacheToSave = {}; this.cache.forEach((tokenInfo, address) => { cacheToSave[address] = tokenInfo; }); await fs3.writeFile(this.cachePath, JSON.stringify(cacheToSave, null, 2)); console.log(`\u{1F4BE} \u4FDD\u5B58\u4EE3\u5E63\u7DE9\u5B58: ${Object.keys(cacheToSave).length} \u500B\u4EE3\u5E63 (${this.chainName} \u93C8)`); } catch (error) { console.log(`\u26A0\uFE0F \u8B66\u544A: \u4FDD\u5B58\u7DE9\u5B58\u5931\u6557\uFF0C\u7E7C\u7E8C\u4F7F\u7528\u5167\u5B58\u7DE9\u5B58: ${error.message}`); } } /** * 獲取單個代幣資訊 * @param {string} tokenAddress - 代幣地址 * @returns {Object|null} 代幣資訊 {symbol, decimals, name} 或 null */ async fetchTokenInfo(tokenAddress) { if (!tokenAddress || tokenAddress === "ETH" || tokenAddress === "0x0000000000000000000000000000000000000000") { return { symbol: "ETH", decimals: 18, name: "Ethereum" }; } const normalizedAddress = tokenAddress.toLowerCase(); if (this.cache.has(normalizedAddress)) { return this.cache.get(normalizedAddress); } if (!this.enableOnChainFetch) { return null; } try { console.log(`\u{1F50D} \u67E5\u8A62\u4EE3\u5E63\u8CC7\u8A0A: ${tokenAddress.slice(0, 6)}...${tokenAddress.slice(-4)}`); const contract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider); const [symbol, decimals, name] = await Promise.all([contract.symbol(), contract.decimals(), contract.name()]); if (!symbol || symbol.trim() === "") { throw new Error("Invalid symbol returned"); } const tokenInfo = { symbol, decimals: Number(decimals) || 18, name: name || "Unknown Token" }; this.cache.set(normalizedAddress, tokenInfo); console.log(`\u2705 \u7372\u53D6\u6210\u529F: ${tokenInfo.symbol} (${tokenInfo.decimals} decimals)`); return tokenInfo; } catch (error) { console.log(`\u26A0\uFE0F \u67E5\u8A62\u5931\u6557: ${tokenAddress.slice(0, 6)}...${tokenAddress.slice(-4)} - ${error.message}`); const fallbackInfo = { symbol: "UNKNOWN", decimals: 18, name: "Unknown Token" }; this.cache.set(normalizedAddress, fallbackInfo); return fallbackInfo; } } /** * 批量獲取代幣資訊 * @param {Array<string>} tokenAddresses - 代幣地址數組 */ async batchFetchTokenInfo(tokenAddresses) { const uniqueAddresses = [ ...new Set( tokenAddresses.filter((addr) => addr && addr !== "ETH" && addr !== "0x0000000000000000000000000000000000000000") ) ].map((addr) => addr.toLowerCase()); const unknownAddresses = uniqueAddresses.filter((addr) => !this.cache.has(addr)); if (unknownAddresses.length === 0) { console.log(`\u{1F4E6} \u6240\u6709\u4EE3\u5E63\u8CC7\u8A0A\u90FD\u5728\u7DE9\u5B58\u4E2D`); return; } if (!this.enableOnChainFetch) { console.log(`\u26A1 \u5FEB\u901F\u6A21\u5F0F: \u8DF3\u904E ${unknownAddresses.length} \u500B\u672A\u77E5\u4EE3\u5E63\u7684\u93C8\u4E0A\u67E5\u8A62`); return; } console.log(`\u{1F50D} \u9700\u8981\u67E5\u8A62 ${unknownAddresses.length} \u500B\u65B0\u4EE3\u5E63...`); const BATCH_SIZE = 5; for (let i = 0; i < unknownAddresses.length; i += BATCH_SIZE) { const batch = unknownAddresses.slice(i, i + BATCH_SIZE); await Promise.all(batch.map((addr) => this.fetchTokenInfo(addr))); } } /** * 從緩存獲取代幣資訊(同步) * @param {string} tokenAddress - 代幣地址 * @returns {Object|null} 代幣資訊或 null */ getTokenInfo(tokenAddress) { if (!tokenAddress || tokenAddress === "ETH" || tokenAddress === "0x0000000000000000000000000000000000000000") { return { symbol: "ETH", decimals: 18, name: "Ethereum" }; } const normalizedAddress = tokenAddress.toLowerCase(); return this.cache.get(normalizedAddress) || null; } /** * 檢查代幣是否在緩存中 * @param {string} tokenAddress - 代幣地址 * @returns {boolean} */ hasTokenInfo(tokenAddress) { if (!tokenAddress || tokenAddress === "ETH" || tokenAddress === "0x0000000000000000000000000000000000000000") { return true; } const normalizedAddress = tokenAddress.toLowerCase(); return this.cache.has(normalizedAddress); } /** * 從訂單數組中提取唯一代幣並批量獲取代幣資訊,返回增強的訂單數組 * @param {Array<Object>} orders - 訂單數組 * @returns {Array<Object>} 增強後的訂單數組,包含 inputTokenSymbol, outputTokenSymbol, tokenPairSymbol */ async enrichOrdersWithTokenInfo(orders) { if (!orders || orders.length === 0) { return []; } const uniqueTokens = /* @__PURE__ */ new Set(); orders.forEach((order) => { const inputToken = order.resolvedOrder?.input?.token || order.priorityOrder?.input?.token; if (inputToken && inputToken !== "N/A") { uniqueTokens.add(inputToken); } const swapper = order.priorityOrder?.info?.swapper?.toLowerCase() || ""; const userOutput = order.resolvedOrder?.outputs?.find((o) => o.recipient?.toLowerCase() === swapper); const outputToken = userOutput ? userOutput.token : order.resolvedOrder?.outputs?.[0]?.token || "N/A"; if (outputToken && outputToken !== "N/A") { uniqueTokens.add(outputToken); } }); if (uniqueTokens.size > 0) { await this.batchFetchTokenInfo([...uniqueTokens]); await this.saveCache(); } return orders.map((order) => { const inputToken = order.resolvedOrder?.input?.token || order.priorityOrder?.input?.token || "N/A"; const swapper = order.priorityOrder?.info?.swapper?.toLowerCase() || ""; const userOutput = order.resolvedOrder?.outputs?.find((o) => o.recipient?.toLowerCase() === swapper); const outputToken = userOutput ? userOutput.token : order.resolvedOrder?.outputs?.[0]?.token || "N/A"; const inputTokenInfo = this.getTokenInfo(inputToken); const outputTokenInfo = this.getTokenInfo(outputToken); const inputSymbol = this.truncateTokenSymbol(inputTokenInfo?.symbol || "UNKNOWN"); const outputSymbol = this.truncateTokenSymbol(outputTokenInfo?.symbol || "UNKNOWN"); return { ...order, inputToken, outputToken, inputTokenSymbol: inputSymbol, outputTokenSymbol: outputSymbol, tokenPairSymbol: `${inputSymbol}-${outputSymbol}` }; }); } /** * 截斷代幣符號至最大 7 個字符 * @param {string} symbol - 代幣符號 * @returns {string} 截斷後的符號 */ truncateTokenSymbol(symbol) { if (!symbol || symbol.length <= 7) return symbol; return symbol.substring(0, 4) + "..."; } /** * 獲取緩存統計資訊 */ getCacheStats() { const totalTokens = this.cache.size; return { chainName: this.chainName, totalTokens, enableOnChainFetch: this.enableOnChainFetch, initialized: this.initialized }; } }; // build-temp/clients/PortusApiClient.js var PortusApiClient = class { /** * 構造函數 * @param {Object} options - 配置選項 * @param {string} options.baseUrl - API 基礎 URL * @param {string} options.apiKey - API 密鑰 */ constructor(options = {}) { this.baseUrl = options.baseUrl || process.env.PORTUS_API_URL; this.apiKey = options.apiKey || process.env.PORTUS_API_KEY; this.isAvailable = !!(this.baseUrl && this.apiKey); if (this.isAvailable) { console.log(`\u{1F517} Portus API \u5BA2\u6236\u7AEF\u521D\u59CB\u5316 URL: ${this.baseUrl} \u{1F511} API Key: \u5DF2\u8A2D\u5B9A`); } else { console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0CSpeed \u985E\u578B\u6AA2\u6E2C\u5C07\u88AB\u7981\u7528"); } } /** * 獲取訂單的填充數據 * @param {string} orderHash - 訂單哈希 * @returns {Promise<Array>} 填充數據列表 */ async getOrderFills(orderHash) { if (!this.isAvailable) { throw new Error("Portus API \u672A\u914D\u7F6E"); } try { console.log(`\u{1F50D} \u5F9E Portus \u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA: ${orderHash}`); const response = await fetch(`${this.baseUrl}/uniswapx-fills/8453`, { method: "POST", headers: { "x-api-key": `${this.apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ orderHashes: [orderHash] }) }); if (!response.ok) { throw new Error(`Portus API \u8ACB\u6C42\u5931\u6557: ${response.status} ${response.statusText}`); } const data = await response.json(); const fills = data.fills[orderHash] || []; console.log(` \u2705 \u627E\u5230 ${fills.length} \u500B\u586B\u5145\u6578\u64DA`); return fills; } catch (error) { console.error(`\u274C \u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA\u5931\u6557: ${error.message}`); throw new Error(`Portus API \u932F\u8AA4: ${error.message}`); } } /** * 批量獲取多個訂單的填充數據 * @param {string[]} orderHashes - 訂單哈希列表 * @param {Object} options - 批次處理選項 * @param {number} options.batchSize - 批次大小,預設 50 * @param {number} options.delay - 批次間延遲(毫秒),預設 200ms * @returns {Promise<Object>} 訂單填充數據映射 */ async getBatchOrderFills(orderHashes, options = {}) { if (!this.isAvailable) { console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0C\u8FD4\u56DE\u7A7A\u586B\u5145\u6578\u64DA"); return {}; } const { batchSize = 100, delay = 100 } = options; const allFills = {}; const totalBatches = Math.ceil(orderHashes.length / batchSize); console.log(`\u{1F50D} \u6279\u91CF\u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA: ${orderHashes.length} \u500B\u8A02\u55AE\uFF0C\u5206 ${totalBatches} \u500B\u6279\u6B21`); for (let i = 0; i < orderHashes.length; i += batchSize) { const batch = orderHashes.slice(i, i + batchSize); const batchIndex = Math.floor(i / batchSize) + 1; try { console.log(` \u6279\u6B21 ${batchIndex}/${totalBatches}: \u8655\u7406 ${batch.length} \u500B\u8A02\u55AE`); const response = await fetch(`${this.baseUrl}/uniswapx-fills/8453`, { method: "POST", headers: { "x-api-key": `${this.apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ orderHashes: batch }) }); if (!response.ok) { console.warn(`\u26A0\uFE0F \u6279\u6B21 ${batchIndex} \u8ACB\u6C42\u5931\u6557: ${response.status} ${response.statusText}`); continue; } const data = await response.json(); if (data.fills) { Object.assign(allFills, data.fills); console.log(` \u2705 \u6279\u6B21 ${batchIndex}: \u7372\u53D6\u5230 ${Object.keys(data.fills).length} \u500B\u8A02\u55AE\u7684\u586B\u5145\u6578\u64DA`); } } catch (error) { console.warn(`\u26A0\uFE0F \u6279\u6B21 ${batchIndex} \u8655\u7406\u5931\u6557: ${error.message}`); } if (i + batchSize < orderHashes.length && delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } } console.log(`\u2705 \u5B8C\u6210\u6279\u91CF\u7372\u53D6\uFF0C\u5171\u7372\u5F97 ${Object.keys(allFills).length} \u500B\u8A02\u55AE\u7684\u586B\u5145\u6578\u64DA`); return allFills; } /** * 獲取訂單的處理時間窗口 (計算處理間隔) * @param {string[]} orderHashes - 訂單哈希列表 * @returns {Promise<Object>} 訂單處理間隔映射,包含計算後的時間間隔 */ async getOrdersProcessingWindows(orderHashes) { if (!this.isAvailable) { console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0C\u8FD4\u56DE\u7A7A\u8655\u7406\u6642\u9593\u7A97\u53E3\u6578\u64DA"); return {}; } try { console.log(`\u{1F50D} \u6279\u91CF\u7372\u53D6\u8A02\u55AE\u8655\u7406\u6642\u9593\u7A97\u53E3: ${orderHashes.length} \u500B\u8A02\u55AE`); const response = await fetch(`${this.baseUrl}/uniswapx-orders/8453`, { method: "POST", headers: { "x-api-key": `${this.apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ orderHashes }) }); if (!response.ok) { throw new Error(`Portus API \u8ACB\u6C42\u5931\u6557: ${response.status} ${response.statusText}`); } const data = await response.json(); if (!data.orders) { console.debug("\u672A\u627E\u5230\u6279\u91CF\u8A02\u55AE\u7684\u8655\u7406\u6642\u9593\u6578\u64DA"); return {}; } const processingWindowMap = {}; for (const [orderHash, orderData] of Object.entries(data.orders)) { if (orderData && orderData.pgaBlockAt && (orderData.receivedAt || orderData.pollingAt)) { try { let baseTime; const receivedTime = orderData.receivedAt ? new Date(orderData.receivedAt) : null; const pollingTime = orderData.pollingAt ? new Date(orderData.pollingAt) : null; const pgaTime = new Date(orderData.pgaBlockAt); if (receivedTime && pollingTime && !isNaN(receivedTime.getTime()) && !isNaN(pollingTime.getTime())) { baseTime = Math.min(pollingTime.getTime(), receivedTime.getTime()); } else if (receivedTime && !isNaN(receivedTime.getTime())) { baseTime = receivedTime.getTime(); } else if (pollingTime && !isNaN(pollingTime.getTime())) { baseTime = pollingTime.getTime(); } else { continue; } if (!isNaN(pgaTime.getTime())) { const processingInterval = pgaTime.getTime() - baseTime; processingWindowMap[orderHash] = { processingIntervalMs: processingInterval, receivedAt: orderData.receivedAt, pollingAt: orderData.pollingAt, pgaBlockAt: orderData.pgaBlockAt }; } } catch (error) { console.warn(`\u26A0\uFE0F \u8A08\u7B97\u8A02\u55AE ${orderHash} \u8655\u7406\u6642\u9593\u7A97\u53E3\u5931\u6557: ${error.message}`); } } } console.log(` \u2705 \u627E\u5230 ${Object.keys(processingWindowMap).length} \u500B\u8A02\u55AE\u7684\u8655\u7406\u6642\u9593\u7A97\u53E3`); return processingWindowMap; } catch (error) { console.error(`\u6279\u91CF\u7372\u53D6\u8A02\u55AE\u8655\u7406\u6642\u9593\u7A97\u53E3\u5931\u6557: ${error.message}`); throw error; } } /** * 測試 API 連接 * @returns {Promise<boolean>} 連接是否成功 */ async testConnection() { if (!this.isAvailable) { console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0C\u7121\u6CD5\u6E2C\u8A66\u9023\u63A5"); return false; } try { const response = await fetch(`${this.baseUrl}/uniswapx-fills/8453/health`, { headers: { "x-api-key": `${this.apiKey}`, "Content-Type": "application/json" } }); if (response.ok) { console.log("\u2705 Portus API \u9023\u63A5\u6210\u529F"); return true; } else { console.warn(`\u26A0\uFE0F Portus API \u9023\u63A5\u7570\u5E38: ${response.status} ${response.statusText}`); return false; } } catch (error) { console.error(`\u274C Portus API \u9023\u63A5\u6E2C\u8A66\u5931\u6557: ${error.message}`); return false; } } /** * 查找最早的 Portus fill(用於非 Portus 訂單) * @param {Array} fills - Portus fills 陣列 * @param {number} pgaBlock - PGA block number * @returns {Object|null} 最早的 fill 或 null */ findEarliestPortusFill(fills, pgaBlock) { if (!fills || !Array.isArray(fills) || fills.length === 0) { return null; } try { const validFills = fills.filter((fill) => { const blockNumber = fill.blockNumber || 0; return blockNumber >= pgaBlock; }); if (validFills.length === 0) { return null; } validFills.sort((a, b) => { const blockA = a.blockNumber || 0; const blockB = b.blockNumber || 0; if (blockA !== blockB) { return blockA - blockB; } const txIndexA = a.transactionIndex || 0; const txIndexB = b.transactionIndex || 0; return txIndexA - txIndexB; }); return validFills[0]; } catch (error) { console.warn(`\u26A0\uFE0F \u67E5\u627E\u6700\u65E9 Portus fill \u5931\u6557: ${error.message}`); return null; } } }; // build-temp/utils/FormattingUtils.js var FormattingUtils = class { /** * Format number with appropriate suffix * @param {number} num - Number to format * @param {number} decimals - Decimal places (default: 2) * @returns {string} Formatted number */ static formatNumber(num, decimals = 2) { if (num >= 1e6) { return `${(num / 1e6).toFixed(1)}M`; } else if (num >= 1e4) { return `${(num / 1e3).toFixed(1)}K`; } else if (num >= 1e3) { return num.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } return num.toFixed(decimals); } /** * Format USD amount with appropriate suffix * @param {number} num - Amount to format * @param {number} forceDecimals - Force specific decimal places (optional) * @returns {string} Formatted USD amount */ static formatUSD(num, forceDecimals = null) { if (forceDecimals !== null) { if (num >= 1e6) { return `$${(num / 1e6).toFixed(forceDecimals)}M`; } else if (num >= 1e4) { return `$${(num / 1e3).toFixed(forceDecimals)}K`; } return `$${num.toLocaleString("en-US", { minimumFractionDigits: forceDecimals, maximumFractionDigits: forceDecimals })}`; } if (num >= 1e6) { return `$${(num / 1e6).toFixed(1)}M`; } else if (num >= 1e4) { return `$${(num / 1e3).toFixed(1)}K`; } const options = { minimumFractionDigits: 0, maximumFractionDigits: 0 }; if (num < 1) { options.minimumFractionDigits = 2; options.maximumFractionDigits = 2; } else if (num < 10) { options.minimumFractionDigits = 1; options.maximumFractionDigits = 1; } return `$${num.toLocaleString("en-US", options)}`; } /** * Format percentage * @param {number} num - Ratio (0-1) * @param {number} forceDecimals - Force specific decimal places (optional) * @returns {string} Formatted percentage */ static formatPercentage(num, forceDecimals = null) { const percentage = num * 100; if (forceDecimals !== null) { return `${percentage.toFixed(forceDecimals)}%`; } return `${percentage.toFixed(percentage < 10 ? 2 : 1)}%`; } }; // build-temp/analyzers/SolvedTypeAnalyzer.js var SolvedTypeAnalyzer = class { /** * Constructor * @param {Object} options - Configuration options * @param {TokenInfoManager} options.tokenInfoManager - Token info manager instance * @param {PortusApiClient} options.portusApiClient - Portus API client instance * @param {Object} options.dataAccessor - Data access object (DataAccessManager or GoogleDriveApiClient) */ constructor(options = {}) { this.tokenInfoManager = options.tokenInfoManager; this.portusApiClient = options.portusApiClient; this.dataAccessor = options.dataAccessor; this.PORTUS_ADDRESS = "0x606A2c650B1c692686a1370ee586E4A3d711d8b0".toLowerCase(); } /** * Truncate token symbol to max 7 characters * @param {string} symbol - Token symbol * @returns {string} Truncated symbol */ truncateTokenSymbol(symbol) { if (!symbol || symbol.length <= 7) return symbol; return symbol.substring(0, 4) + "..."; } /** * Calculate PGA Block * @param {Object} order - Order object * @returns {number} PGA Block number */ calculatePgaBlock(order) { const auctionStartBlock = parseInt(order.priorityOrder?.auctionStartBlock || 0); const auctionTargetBlock = parseInt(order.priorityOrder?.cosignerData?.auctionTargetBlock || 0); return Math.min(auctionStartBlock, auctionTargetBlock) || 0; } /** * Get output token address * @param {Object} order - Order data * @returns {string} Output token address */ getOutputToken(order) { const swapper = order.priorityOrder?.info?.swapper?.toLowerCase() || ""; const userOutput = order.resolvedOrder?.outputs?.find((o) => o.recipient?.toLowerCase() === swapper); return userOutput ? userOutput.token : order.resolvedOrder?.outputs?.[0]?.token || "N/A"; } /** * Get Speed order processing window range * @param {number} processingWindowMs - Processing window in milliseconds * @returns {string} Range identifier */ getSpeedWindowRange(processingWindowMs) { if (processingWindowMs < 2e3) return "<2000ms"; if (processingWindowMs < 2100) return "<2100ms"; if (processingWindowMs < 2200) return "<2200ms"; if (processingWindowMs < 2300) return "<2300ms"; if (processingWindowMs < 2400) return "<2400ms"; if (processingWindowMs < 2500) return "<2500ms"; if (processingWindowMs < 2600) return "<2600ms"; if (processingWindowMs < 2700) return "<2700ms"; if (processingWindowMs < 2800) return "<2800ms";