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
JavaScript
// 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";