UNPKG

uniswapx-analyzer-mcp

Version:

Model Context Protocol server for UniswapX trading analytics and metrics with cloud deployment support

1,303 lines (1,296 loc) 110 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/PackageDataService.js import dotenv3 from "dotenv"; // 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() { this.folderId = process.env.GOOGLE_DRIVE_FOLDER_ID; this.apiKey = process.env.GOOGLE_API_KEY; this.cacheDir = path.join(os.tmpdir(), "uniswapx-cache"); 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 * @param {string} namePattern - Optional pattern to filter files * @returns {Promise<Array>} List of files */ async listFiles(namePattern = null) { try { const query = `'${this.folderId}' 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 fs2 from "fs/promises"; import path2 from "path"; import { ethers } from "ethers"; import dotenv from "dotenv"; dotenv.config(); var BUILT_IN_TOKENS = { ETH: { symbol: "ETH", decimals: 18, name: "Ethereum" }, "0x4200000000000000000000000000000000000006": { symbol: "WETH", decimals: 18, name: "Wrapped Ether" }, "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": { symbol: "USDC", decimals: 6, name: "USD Coin" }, "0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22": { symbol: "cbETH", decimals: 18, name: "Coinbase Wrapped Staked ETH" }, "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42": { symbol: "EURC", decimals: 6, name: "EURC" }, "0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452": { symbol: "WSTETH", decimals: 18, name: "Wrapped liquid staked Ether 2.0" } }; var ERC20_ABI = [ "function symbol() view returns (string)", "function decimals() view returns (uint8)", "function name() view returns (string)" ]; var TokenInfoManager = class { constructor(options = {}) { this.rpcUrl = 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 = this.getEnvironmentAwareCachePath(); this.loadBuiltInTokens(); } /** * Get environment-aware cache path * @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.json"; } else { return "./data/token-cache.json"; } } /** * 載入內建代幣列表到緩存 */ loadBuiltInTokens() { Object.entries(BUILT_IN_TOKENS).forEach(([address, tokenInfo]) => { this.cache.set(address.toLowerCase(), tokenInfo); }); } /** * 初始化管理器,載入緩存文件 */ async initialize() { if (this.initialized) return true; try { await fs2.mkdir(path2.dirname(this.cachePath), { recursive: true }); const cacheData = await fs2.readFile(this.cachePath, "utf8"); const cacheObj = JSON.parse(cacheData); Object.entries(cacheObj).forEach(([address, tokenInfo]) => { const normalizedAddress = address.toLowerCase(); if (!this.cache.has(normalizedAddress)) { this.cache.set(normalizedAddress, tokenInfo); } }); console.log( `\u{1F4E6} \u8F09\u5165\u4EE3\u5E63\u7DE9\u5B58: ${this.cache.size} \u500B\u4EE3\u5E63 (\u5305\u542B ${Object.keys(BUILT_IN_TOKENS).length} \u500B\u5167\u5EFA\u4EE3\u5E63)` ); } catch (error) { console.log(`\u{1F4E6} \u5275\u5EFA\u65B0\u7684\u4EE3\u5E63\u7DE9\u5B58\u6587\u4EF6\uFF0C\u4F7F\u7528 ${Object.keys(BUILT_IN_TOKENS).length} \u500B\u5167\u5EFA\u4EE3\u5E63 (${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) => { if (!BUILT_IN_TOKENS[address]) { cacheToSave[address] = tokenInfo; } }); await fs2.writeFile(this.cachePath, JSON.stringify(cacheToSave, null, 2)); console.log(`\u{1F4BE} \u4FDD\u5B58\u4EE3\u5E63\u7DE9\u5B58: ${Object.keys(cacheToSave).length} \u500B\u81EA\u5B9A\u7FA9\u4EE3\u5E63`); } 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 BUILT_IN_TOKENS["ETH"]; } 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 BUILT_IN_TOKENS["ETH"]; } 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); } /** * 獲取緩存統計資訊 */ getCacheStats() { const builtInTokens = Object.keys(BUILT_IN_TOKENS).length; const totalTokens = this.cache.size; const customTokens = totalTokens - builtInTokens; return { totalTokens, builtInTokens, customTokens, enableOnChainFetch: this.enableOnChainFetch, initialized: this.initialized }; } }; // build-temp/clients/PortusApiClient.js import dotenv2 from "dotenv"; dotenv2.config(); 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 - 訂單哈希列表 * @returns {Promise<Object>} 訂單填充數據映射 */ async getBatchOrderFills(orderHashes) { if (!this.isAvailable) { console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0C\u8FD4\u56DE\u7A7A\u586B\u5145\u6578\u64DA"); return {}; } try { console.log(`\u{1F50D} \u6279\u91CF\u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA: ${orderHashes.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 }) }); if (!response.ok) { throw new Error(`Portus API \u8ACB\u6C42\u5931\u6557: ${response.status} ${response.statusText}`); } const data = await response.json(); if (!data.fills) { console.debug("\u672A\u627E\u5230\u6279\u91CF\u8A02\u55AE\u7684\u586B\u5145\u6578\u64DA"); return {}; } return data.fills; } catch (error) { console.error(`\u6279\u91CF\u7372\u53D6\u8A02\u55AE\u586B\u5145\u6578\u64DA\u5931\u6557: ${error.message}`); throw error; } } /** * 獲取訂單的時間數據 (receivedAt 和 pgaBlockAt) * @param {string[]} orderHashes - 訂單哈希列表 * @returns {Promise<Object>} 訂單時間數據映射,包含 receivedAt 和 pgaBlockAt */ async getOrdersTimingData(orderHashes) { if (!this.isAvailable) { console.log("\u26A0\uFE0F Portus API \u672A\u914D\u7F6E\uFF0C\u8FD4\u56DE\u7A7A\u6642\u9593\u6578\u64DA"); return {}; } try { console.log(`\u{1F50D} \u6279\u91CF\u7372\u53D6\u8A02\u55AE\u6642\u9593\u6578\u64DA: ${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\u6642\u9593\u6578\u64DA"); return {}; } const receiveTimeMap = {}; for (const [orderHash, orderData] of Object.entries(data.orders)) { if (orderData && orderData.receivedAt && orderData.pgaBlockAt) { receiveTimeMap[orderHash] = { receivedAt: orderData.receivedAt, pgaBlockAt: orderData.pgaBlockAt }; } } console.log(` \u2705 \u627E\u5230 ${Object.keys(receiveTimeMap).length} \u500B\u8A02\u55AE\u7684\u6642\u9593\u6578\u64DA`); return receiveTimeMap; } catch (error) { console.error(`\u6279\u91CF\u7372\u53D6\u8A02\u55AE\u6642\u9593\u6578\u64DA\u5931\u6557: ${error.message}`); throw error; } } /** * 計算訂單時間間隔(共用工具方法) * 使用公式:pgaBlockAt - receivedAt - 2200ms * @param {string} receivedAt - 訂單接收時間 (ISO 字符串) * @param {string} pgaBlockAt - PGA 區塊時間 (ISO 字符串) * @returns {number} 時間間隔(毫秒) */ static calculateOrderTimingInterval(receivedAt, pgaBlockAt) { try { if (!receivedAt || !pgaBlockAt) { throw new Error("\u7F3A\u5C11\u5FC5\u8981\u7684\u6642\u9593\u6233\u53C3\u6578"); } const receivedTime = new Date(receivedAt); const pgaTime = new Date(pgaBlockAt); if (isNaN(receivedTime.getTime()) || isNaN(pgaTime.getTime())) { throw new Error("\u7121\u6548\u7684\u6642\u9593\u6233\u683C\u5F0F"); } return pgaTime.getTime() - receivedTime.getTime() - 2200; } catch (error) { console.warn(`\u26A0\uFE0F \u8A08\u7B97\u6642\u9593\u9593\u9694\u5931\u6557: ${error.message}`); return null; } } /** * 測試 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; } } }; // 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 = "0xF85E95BEF8F2De7782B0936ca3480C41a4b6C59B".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 time interval range * @param {number} timeInterval - Time interval in milliseconds * @returns {string} Range identifier */ getSpeedIntervalRange(timeInterval) { if (timeInterval < 0) return "<0ms"; if (timeInterval < 50) return "<50ms"; if (timeInterval < 100) return "<100ms"; if (timeInterval < 200) return "<200ms"; if (timeInterval < 400) return "<400ms"; return "\u2265400ms"; } /** * Get Resolved order BlockLag range * @param {number} blockLag - Block lag value * @returns {string} Range identifier */ getResolvedBlockLagRange(blockLag) { if (blockLag === 1) return "+1"; if (blockLag === 2) return "+2"; if (blockLag === 3) return "+3"; if (blockLag === 4) return "+4"; if (blockLag >= 5 && blockLag <= 6) return "+5~6"; if (blockLag >= 7 && blockLag <= 9) return "+7~9"; if (blockLag >= 10 && blockLag <= 14) return "+10~14"; if (blockLag >= 15 && blockLag <= 19) return "+15~19"; if (blockLag >= 20 && blockLag <= 29) return "+20~29"; if (blockLag >= 30 && blockLag <= 39) return "+30~39"; if (blockLag >= 40) return "+40+"; return "other"; } /** * Analyze data and generate comprehensive statistics * @param {Array} data - Array of order data to analyze * @returns {Promise<Object>} Analysis results */ async analyzeSolvedTypeData(data) { if (!Array.isArray(data) || data.length === 0) { throw new Error("No valid order data found"); } if (this.tokenInfoManager) { await this.tokenInfoManager.initialize(); const uniqueTokens = /* @__PURE__ */ new Set(); data.forEach((order) => { const inputToken = order.resolvedOrder?.input?.token || order.priorityOrder?.input?.token; const outputToken = this.getOutputToken(order); if (inputToken && inputToken !== "N/A") uniqueTokens.add(inputToken); if (outputToken && outputToken !== "N/A") uniqueTokens.add(outputToken); }); if (uniqueTokens.size > 0) { await this.tokenInfoManager.batchFetchTokenInfo([...uniqueTokens]); } } const speedOrders = data.filter((order) => order.solvedType === "Speed"); let orderTimingData = {}; if (speedOrders.length > 0 && this.portusApiClient?.isAvailable) { try { const orderHashes = speedOrders.map((order) => order.orderHash).filter(Boolean); if (orderHashes.length > 0) { orderTimingData = await this.portusApiClient.getOrdersTimingData(orderHashes); } } catch (error) { console.warn(`\u26A0\uFE0F Failed to fetch timing data, will skip time interval analysis: ${error.message}`); } } const stats = { totalOrders: data.length, totalProfit: 0, solvedTypeStats: /* @__PURE__ */ new Map(), // Normal, Speed, Resolved, Other speedIntervalStats: /* @__PURE__ */ new Map(), // <200ms, <400ms, <600ms, <800ms, <1000ms, ≥1000ms resolvedBlockLagStats: /* @__PURE__ */ new Map(), // +1, +2, +3, +4, +5~6, +7~9, +10+ normalTokenPairStats: /* @__PURE__ */ new Map() // token pair analysis for Normal orders }; for (const order of data) { const fillerProfitUsd = parseFloat(order.orderUsdSummary?.fillerProfitUsd || 0); const solvedType = order.solvedType || "Other"; const blockNumber = parseInt(order.blockNumber || 0); const pgaBlock = this.calculatePgaBlock(order); const isPortus = (order.filler || "").toLowerCase() === this.PORTUS_ADDRESS; stats.totalProfit += fillerProfitUsd; if (!stats.solvedTypeStats.has(solvedType)) { stats.solvedTypeStats.set(solvedType, { orders: 0, profit: 0, portusOrders: 0, portusProfit: 0 }); } const solvedTypeData = stats.solvedTypeStats.get(solvedType); solvedTypeData.orders++; solvedTypeData.profit += fillerProfitUsd; if (isPortus) { solvedTypeData.portusOrders++; solvedTypeData.portusProfit += fillerProfitUsd; } if (solvedType === "Speed") { const orderHash = order.orderHash; const orderTiming = orderTimingData[orderHash]; if (orderTiming && orderTiming.receivedAt && orderTiming.pgaBlockAt) { try { const timeInterval = PortusApiClient.calculateOrderTimingInterval( orderTiming.receivedAt, orderTiming.pgaBlockAt ); if (timeInterval !== null) { const rangeKey = this.getSpeedIntervalRange(timeInterval); if (!stats.speedIntervalStats.has(rangeKey)) { stats.speedIntervalStats.set(rangeKey, { orders: 0, profit: 0, portusOrders: 0, portusProfit: 0 }); } const speedData = stats.speedIntervalStats.get(rangeKey); speedData.orders++; speedData.profit += fillerProfitUsd; if (isPortus) { speedData.portusOrders++; speedData.portusProfit += fillerProfitUsd; } } } catch (error) { console.warn(`\u26A0\uFE0F Failed to calculate time interval for order ${orderHash}: ${error.message}`); } } } if (solvedType === "Resolved" && blockNumber > pgaBlock) { const blockLag = blockNumber - pgaBlock; const rangeKey = this.getResolvedBlockLagRange(blockLag); if (!stats.resolvedBlockLagStats.has(rangeKey)) { stats.resolvedBlockLagStats.set(rangeKey, { orders: 0, profit: 0, portusOrders: 0, portusProfit: 0 }); } const resolvedData = stats.resolvedBlockLagStats.get(rangeKey); resolvedData.orders++; resolvedData.profit += fillerProfitUsd; if (isPortus) { resolvedData.portusOrders++; resolvedData.portusProfit += fillerProfitUsd; } } if (solvedType === "Normal") { const inputToken = order.resolvedOrder?.input?.token || order.priorityOrder?.input?.token || "N/A"; const outputToken = this.getOutputToken(order); if (inputToken !== "N/A" && outputToken !== "N/A") { let inputSymbol, outputSymbol; if (this.tokenInfoManager) { const inputTokenInfo = this.tokenInfoManager.getTokenInfo(inputToken); const outputTokenInfo = this.tokenInfoManager.getTokenInfo(outputToken); inputSymbol = this.truncateTokenSymbol(inputTokenInfo?.symbol || "UNKNOWN"); outputSymbol = this.truncateTokenSymbol(outputTokenInfo?.symbol || "UNKNOWN"); } else { inputSymbol = this.truncateTokenSymbol(`${inputToken.slice(0, 6)}...${inputToken.slice(-4)}`); outputSymbol = this.truncateTokenSymbol(`${outputToken.slice(0, 6)}...${outputToken.slice(-4)}`); } const pairKey = `${inputSymbol}-${outputSymbol}`; if (!stats.normalTokenPairStats.has(pairKey)) { stats.normalTokenPairStats.set(pairKey, { orders: 0, profit: 0, portusOrders: 0, portusProfit: 0 }); } const pairData = stats.normalTokenPairStats.get(pairKey); pairData.orders++; pairData.profit += fillerProfitUsd; if (isPortus) { pairData.portusOrders++; pairData.portusProfit += fillerProfitUsd; } } } } if (this.tokenInfoManager) { await this.tokenInfoManager.saveCache(); } return stats; } /** * Generate Slack message format for SolvedType report * @param {string} startDate - Start date * @param {string} endDate - End date (optional, if null defaults to startDate) * @param {Object} stats - Statistics data * @returns {Object} Slack message object */ generateSlackMessage(startDate, endDate = null, stats) { if (!endDate) { endDate = startDate; } const isSingleDate = startDate === endDate; let headerText, contextText; if (isSingleDate) { const dateStr = new Date(startDate).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", weekday: "long" }); headerText = "\u{1F4CA} SolvedType Analysis Report"; contextText = `*${dateStr}* | Portus team analysis system`; } else { const startDateStr = new Date(startDate).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); const endDateStr = new Date(endDate).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); headerText = "\u{1F4CA} SolvedType Analysis Report"; contextText = `*${startDateStr} - ${endDateStr}* | Portus team analysis system`; } const blocks = [ { type: "header", text: { type: "plain_text", text: headerText } }, { type: "context", elements: [ { type: "mrkdwn", text: contextText } ] }, { type: "divider" } ]; const solvedTypeOrder = ["Normal", "Speed", "Resolved", "Other"]; let overviewTable = `\u{1F50E} *OVERVIEW* \u2022 Total Orders: *${FormattingUtils.formatNumber( stats.totalOrders, 0 )}* | Total Profit: *${FormattingUtils.formatUSD(stats.totalProfit)}* `; overviewTable += `\`\`\` `; overviewTable += `\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 `; overviewTable += `\u2502 Type \u2502 Orders \u2502 Orders % \u2502 Profit \u2502 Profit % \u2502 `; overviewTable += `\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524 `; for (const solvedType of solvedTypeOrder) { const data = stats.solvedTypeStats.get(solvedType); if (data) { const orderPercentage = stats.totalOrders > 0 ? data.orders / stats.totalOrders * 100 : 0; const profitPercentage = stats.totalProfit > 0 ? data.profit / stats.totalProfit * 100 : 0; const typeName = solvedType.padEnd(8, " "); const ordersCount = FormattingUtils.formatNumber(data.orders, 0).padEnd(8, " "); const ordersPercent = FormattingUtils.formatPercentage(orderPercentage / 100).padEnd(8, " "); const profitAmount = FormattingUtils.formatUSD(data.profit).padEnd(8, " "); const profitPercent = FormattingUtils.formatPercentage(profitPercentage / 100).padEnd(8, " "); overviewTable += `\u2502 ${typeName} \u2502 ${ordersCount} \u2502 ${ordersPercent} \u2502 ${profitAmount} \u2502 ${profitPercent} \u2502 `; } else { const typeName = solvedType.padEnd(9, " "); overviewTable += `\u2502 ${typeName} \u2502 0 \u2502 0% \u2502 $0 \u2502 0% \u2502 `; } } overviewTable += `\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 `; overviewTable += `\`\`\``; blocks.push({ type: "section", text: { type: "mrkdwn", text: overviewTable } }); blocks.push({ type: "divider" }); this.addNormalOrdersSection(blocks, stats); this.addSpeedOrdersSection(blocks, stats); this.addResolvedOrdersSection(blocks, stats); return { blocks }; } /** * Add Normal Orders section * @param {Array} blocks - Slack blocks array * @param {Object} stats - Statistics data */ addNormalOrdersSection(blocks, stats) { const normalData = stats.solvedTypeStats.get("Normal"); if (!normalData || normalData.orders === 0) return; blocks.push({ type: "section", text: { type: "mrkdwn", text: "\u{1F699} *NORMAL ORDER*" } }); let normalText = `\u27A4 Total: *${FormattingUtils.formatNumber( normalData.orders, 0 )}* orders | Profit *\`${FormattingUtils.formatUSD(normalData.profit)}\`* `; normalText += `\u27A4 Portus: *${FormattingUtils.formatNumber( normalData.portusOrders, 0 )}* orders | Profit *\`${FormattingUtils.formatUSD(normalData.portusProfit)}\`* | *${FormattingUtils.formatPercentage( normalData.portusProfit / normalData.profit )}* `; const sortedPairs = Array.from(stats.normalTokenPairStats.entries()).sort((a, b) => b[1].profit - a[1].profit).slice(0, 5); normalText += "\u27A4 Market Top Token Pairs:\n"; for (let i = 0; i < sortedPairs.length; i++) { const [pairSymbol, pairData] = sortedPairs[i]; const portusRatio = pairData.profit > 0 ? pairData.portusProfit / pairData.profit * 100 : 0; normalText += `\u251C *${i + 1}. \`${pairSymbol}\`*: *${FormattingUtils.formatNumber( pairData.orders, 0 )}* orders | Profit: *\`${FormattingUtils.formatUSD(pairData.profit)}\`* | Portus: *${FormattingUtils.formatUSD( pairData.portusProfit )}* *\`${FormattingUtils.formatPercentage(portusRatio / 100)}\`* `; } blocks.push({ type: "section", text: { type: "mrkdwn", text: normalText } }); blocks.push({ type: "divider" }); } /** * Add Speed Orders section * @param {Array} blocks - Slack blocks array * @param {Object} stats - Statistics data */ addSpeedOrdersSection(blocks, stats) { const speedData = stats.solvedTypeStats.get("Speed"); if (!speedData || speedData.orders === 0) return; blocks.push({ type: "section", text: { type: "mrkdwn", text: "\u{1F3CE}\uFE0F *SPEED ORDER*" } }); let speedText = `\u27A4 Total: *${FormattingUtils.formatNumber( speedData.orders, 0 )}* orders | Profit *\`${FormattingUtils.formatUSD(speedData.profit)}\`* `; speedText += `\u27A4 Portus: *${FormattingUtils.formatNumber( speedData.portusOrders, 0 )}* orders | Profit *\`${FormattingUtils.formatUSD(speedData.portusProfit)}\`* | *${FormattingUtils.formatPercentage( speedData.portusProfit / speedData.profit )}* `; speedText += "\u27A4 Interval Analysis:\n"; const speedRangeOrder = ["<0ms", "<50ms", "<100ms", "<200ms", "<400ms", "\