UNPKG

navskit

Version:

Deploy TypeScript logic on Ethereum. Includes core library, CLI tools, and utilities.

671 lines (660 loc) 31.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.runOperator = runOperator; const accounts_1 = require("viem/accounts"); const viem_1 = require("viem"); const chains_1 = require("viem/chains"); const child_process_1 = require("child_process"); const deploy_1 = require("../../deploy"); const tmp = __importStar(require("tmp")); const path = __importStar(require("path")); const fs = __importStar(require("fs")); const child_process = __importStar(require("child_process")); const util = __importStar(require("util")); const ora_1 = __importDefault(require("ora")); const npm_package_arg_1 = __importDefault(require("npm-package-arg")); // Get package version function getNavskitVersion() { try { const packageJsonPath = path.join(__dirname, '../../../package.json'); if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); return packageJson.version || 'unknown'; } } catch (_error) { // Fallback: try to read from node_modules try { const packageJsonPath = path.join(__dirname, '../../../node_modules/navskit/package.json'); if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); return packageJson.version || 'unknown'; } } catch (_fallbackError) { // Ignore fallback errors } } return 'unknown'; } const exec = util.promisify(child_process.exec); const mkdir = util.promisify(fs.mkdir); const writeFile = util.promisify(fs.writeFile); // Function to get current package information function getCurrentPackageInfo() { try { const packageJsonPath = path.join(process.cwd(), 'package.json'); if (fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); return { name: packageJson.name, version: packageJson.version || '1.0.0' }; } } catch (error) { console.warn('Could not read package.json from current directory:', error); } return null; } /** * Downloads and prepares a NAVS package for operation */ async function prepareNavsPackage(packageName, packageVersion, options, isLocal = false) { const spinner = (0, ora_1.default)(`Preparing ${packageName}@${packageVersion} ${isLocal ? '(local)' : ''}`).start(); try { // Create a temporary directory for the operator const tempDirRoot = options.tempDir || tmp.dirSync().name; const operatorDir = path.join(tempDirRoot, `navs-operator-${packageName}-${Date.now()}`); await mkdir(operatorDir, { recursive: true }); // For local packages, use file:// dependency instead of npm version const dependencyVersion = isLocal ? `file:${process.cwd()}` : packageVersion; // Create a package.json const packageJson = { name: `navs-operator-${packageName}`, version: '1.0.1', private: true, type: 'module', dependencies: { [packageName]: dependencyVersion, // Use file:// for local, npm version otherwise // Only include navskit dependency if not in local mode (will use npm link instead) ...(isLocal ? {} : { 'navskit': "latest" }), 'viem': 'latest' } }; await writeFile(path.join(operatorDir, 'package.json'), JSON.stringify(packageJson, null, 2)); // Install the package spinner.text = `Installing ${packageName}...`; await exec('npm install', { cwd: operatorDir }); // For local mode, link to the local navskit directory to avoid duplicates if (isLocal) { spinner.text = `Linking local navskit...`; const navskitPath = path.join(process.cwd(), 'node_modules', 'navskit'); await exec(`npm link "${navskitPath}"`, { cwd: operatorDir }); } // Parse the package name to get the actual name (without version/tag) const parsedPkg = (0, npm_package_arg_1.default)(packageName); const packageNameOnly = parsedPkg.name || packageName; // Create the operator script const operatorScript = createPackageWorkerScript(packageNameOnly, options.rpcUrl); await writeFile(path.join(operatorDir, 'worker.js'), operatorScript); spinner.succeed(`Package ${packageName}@${packageVersion} prepared successfully in ${operatorDir} ${isLocal ? '(local)' : ''}`); return operatorDir; } catch (error) { spinner.fail(`Failed to prepare package ${packageName}@${packageVersion}`); throw error; } } /** * Runs the central operator logic directly in this process */ async function runCentralOperator(options, onPackageRequest) { // In watch mode, we don't need a private key let account; if (!options.watch) { // Load account from environment const privateKey = process.env.WALLET_PK; if (!privateKey) { console.error('WALLET_PK environment variable is required'); process.exit(1); } // Create account instance const formattedKey = privateKey.startsWith('0x') ? privateKey : `0x${privateKey}`; account = (0, accounts_1.privateKeyToAccount)(formattedKey); } // Create public client to listen for events const publicClient = (0, viem_1.createPublicClient)({ chain: chains_1.baseSepolia, pollingInterval: 200, transport: options.rpcUrl.startsWith('wss://') ? (0, viem_1.webSocket)(options.rpcUrl) : (0, viem_1.http)(options.rpcUrl) }); // TaskDispatch contract address (from navs-deploy) const TASK_DISPATCH_ADDRESS = deploy_1.Addresses[84532].TaskDispatch; // Base Sepolia // Track active package workers const packageWorkers = new Map(); const blacklist = new Set(); // Track local task results for comparison (watch mode) const localTaskResults = new Map(); // Display startup header with version and mode information const version = getNavskitVersion(); console.log(`\n🚀 Starting NAVS Operator v${version}`); console.log(`===================================`); // Determine and display operator mode let operatorMode = 'NORMAL'; let modeDescription = 'Full operator mode - processes all tasks and submits results'; if (options.watch) { operatorMode = 'WATCH'; modeDescription = 'Watch mode - processes tasks locally and compares with onchain results (read-only)'; } else if (options.local) { operatorMode = 'LOCAL'; modeDescription = 'Local mode - only processes tasks for the current package directory'; } console.log(`🎯 Mode: ${operatorMode}`); console.log(`📝 ${modeDescription}`); console.log(`📡 Contract: ${TASK_DISPATCH_ADDRESS}`); console.log(`🌐 RPC URL: ${options.rpcUrl}`); if (account) { console.log(`👤 Operator Account: ${account.address}`); } else { console.log(`👁️ No signing account (read-only mode)`); } if (options.local) { const currentPackage = getCurrentPackageInfo(); if (currentPackage) { console.log(`📦 Local Package: ${currentPackage.name}@${currentPackage.version}`); } else { throw new Error(`⚠️ No package.json found in current directory. Please run from within an npm package.`); } } console.log(`===================================\n`); // Function to handle JSON results from package workers async function handleWorkerResult(workerResult, serviceName, serviceVersion, functionName) { try { const { consensus: _consensus, taskId, result, resultType, extra } = workerResult; console.log(`🔐 Processing result for task ${taskId} from worker...`); if (options.watch) { // Watch mode: store result locally for later comparison console.log(`👁️ Watch mode: Storing local result for task ${taskId}`); localTaskResults.set(taskId, { taskId, serviceName: serviceName || 'unknown', serviceVersion: serviceVersion || 'unknown', functionName: functionName || 'unknown', result, resultType, extra: extra || '0x0000000000000000000000000000000000000000000000000000000000000000', timestamp: Date.now() }); console.log(`📊 Local result stored: ${resultType} = ${result.slice(0, 100)}${result.length > 100 ? '...' : ''}`); return; } // Sign the l1calldatahash if provided, and pass it up as the `extra` field. let signedL1Calldata = '0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; if (extra && extra !== '0x0000000000000000000000000000000000000000000000000000000000000000') { if (!account) { console.error('❌ No account available for signing in non-watch mode'); return; } const sig = await account.signMessage({ message: extra }); const { r, s, v: _v, yParity } = (0, viem_1.parseSignature)(sig); const v = _v == undefined ? (yParity + 27) : Number(_v); signedL1Calldata = (0, viem_1.encodeAbiParameters)([{ type: 'uint8' }, { type: 'bytes32' }, { type: 'bytes32' }], [v, r, s]); } if (!options.watch) { if (!account) { console.error('❌ No account available for signing in non-watch mode'); return; } // Create wallet client for signing and submission const walletClient = (0, viem_1.createWalletClient)({ chain: chains_1.baseSepolia, account: account, pollingInterval: 200, transport: (options.rpcUrl.startsWith('wss://') ? (0, viem_1.webSocket)(options.rpcUrl) : (0, viem_1.http)(options.rpcUrl)) }); const taskDispatchWrite = (0, viem_1.getContract)({ abi: deploy_1.TaskDispatchAbi, address: TASK_DISPATCH_ADDRESS, client: walletClient }); // Submit the result to the TaskDispatch contract const hash = await taskDispatchWrite.write.submitResult([ BigInt(taskId), { success: true, resultType, result, extra: signedL1Calldata } ]); console.log(`✅ Successfully submitted result for task ${taskId}`, { hash }); } } catch (error) { console.error(`❌ Failed to process worker result:`, error); } } // Function to start a package worker async function startPackageWorker(serviceName, serviceVersion, isLocal = false) { try { const serviceKey = `${serviceName}@${serviceVersion}`; if (blacklist.has(serviceKey)) { console.warn(`Ignoring ${serviceKey} - this worker previously crashed.`); return; } console.log(`🚀 Spawning worker process for: ${serviceKey} ${isLocal ? '[LOCAL]' : ''}`); // Reserve the slot immediately to prevent race conditions packageWorkers.set(serviceKey, { process: null, // Will be set after preparation path: '' }); try { // Prepare the package const workerPath = await onPackageRequest(serviceName, serviceVersion, isLocal); console.log(`📦 Package ready for ${serviceKey}, starting worker...`); // Start the worker process const worker = (0, child_process_1.spawn)('node', ['worker.js'], { cwd: workerPath, env: { ...process.env }, stdio: ['inherit', 'pipe', 'inherit', 'ipc'] // Pipe stdout to capture JSON output }); // Update the worker entry with actual process and path packageWorkers.set(serviceKey, { process: worker, path: workerPath }); // Handle JSON output from worker worker.stdout?.on('data', (data) => { const output = data.toString(); const lines = output.split('\n').filter((line) => line.trim()); for (const line of lines) { try { const jsonResult = JSON.parse(line); if (jsonResult.taskId && jsonResult.result !== undefined) { console.log(`📤 Received result from worker ${serviceKey}:`, jsonResult); handleWorkerResult(jsonResult, serviceName, serviceVersion, jsonResult.functionName).catch(error => { console.error(`Failed to process worker result:`, error); }); } } catch (_error) { // Not JSON or not a task result - just log as regular output console.log(`[${serviceKey}] ${line}`); } } }); worker.on('exit', (code) => { console.log(`Worker for ${serviceKey} exited with code ${code}`); packageWorkers.delete(serviceKey); blacklist.add(serviceKey); }); worker.on('error', (error) => { console.error(`Worker error for ${serviceKey}:`, error); packageWorkers.delete(serviceKey); }); } catch (error) { console.error(`Failed to prepare package for ${serviceKey}:`, error); // Remove the reserved slot since preparation failed packageWorkers.delete(serviceKey); throw error; } } catch (error) { console.error(`Failed to start worker for ${serviceName}@${serviceVersion}:`, error); } } // Function to process a single task and start workers if needed async function processTask(serviceName, serviceVersion, functionName, taskId, isLocal = false) { try { console.log(`📋 Processing task: ${serviceName}@${serviceVersion}.${functionName} (ID: ${taskId}) ${isLocal ? '[LOCAL]' : ''}`); // If operator is in local mode, only process local tasks that match current package if (options.local) { if (!isLocal) { console.log(`⏭️ Skipping non-local task ${taskId} (operator in local mode)`); return; } // Check if task matches current package directory const currentPackageJson = getCurrentPackageInfo(); if (!currentPackageJson || currentPackageJson.name !== serviceName) { console.log(`⏭️ Skipping local task ${taskId} for different package ${serviceName} (current: ${currentPackageJson?.name || 'unknown'})`); return; } } // If operator is NOT in local mode, skip local tasks (watch mode processes all for comparison) if (!options.local && !options.watch && isLocal) { console.log(`⏭️ Skipping local task ${taskId} (operator not in local mode)`); return; } // Create a unique key for service name + version const serviceKey = `${serviceName}@${serviceVersion}`; // Check if we have a worker for this service version if (!packageWorkers.has(serviceKey)) { console.log(`🔄 Starting new worker for service: ${serviceKey}`); await startPackageWorker(serviceName, serviceVersion, isLocal); } else { console.log(`✅ Worker already running for service: ${serviceKey}`); } } catch (error) { console.error('Error processing task:', error); } } // Function to scan for historical unanswered tasks async function processHistoricalTasks() { console.log('🔍 Scanning for historical tasks...'); try { // Get current task ID counter let nextTaskId; try { nextTaskId = await publicClient.readContract({ address: TASK_DISPATCH_ADDRESS, abi: deploy_1.TaskDispatchAbi, functionName: 'nextTaskId', blockTag: 'pending' }); } catch (e) { console.error(`Failed to read nextTaskId`, e); throw e; } console.log(`📊 Scanning tasks 1 to ${nextTaskId}...`); // Check all existing tasks for (let i = BigInt(1); i < nextTaskId; i++) { try { // Get task details const taskStatus = await publicClient.readContract({ address: TASK_DISPATCH_ADDRESS, abi: deploy_1.TaskDispatchAbi, functionName: 'getTaskStatus', blockTag: 'pending', args: [i], }); const [exists, completed, failed] = taskStatus; // Only process tasks that exist but aren't completed or failed if (exists && !completed && !failed) { // Get task details const taskDetails = await publicClient.readContract({ address: TASK_DISPATCH_ADDRESS, abi: deploy_1.TaskDispatchAbi, functionName: 'getTaskDetails', blockTag: 'pending', args: [i], }); const [serviceName, serviceVersion, functionName, _args, _stakeThreshold, _remainingStake, _isConsensus, _consensusType, isLocal, _deterministic] = taskDetails; if (serviceName && serviceVersion && functionName) { await processTask(serviceName, serviceVersion, functionName, i, isLocal); } } } catch (error) { // Skip tasks that error (may not exist or have other issues) console.debug(`Skipping task ${i}:`, error); } } console.log(`✅ Historical task scan complete (scanned tasks 1-${nextTaskId})`); } catch (error) { console.error(`❌ Error scanning historical tasks:`, error); } // Schedule the next scan setTimeout(processHistoricalTasks, 60000); // Scan every minute } // Start historical task scanning console.log('🔍 Starting historical task scanning...'); processHistoricalTasks().catch(err => console.error("❌ Error in historical task scan:", err)); // Listen for new TaskRequested events console.log('👂 Starting event listener for new tasks...'); const unwatch = publicClient.watchContractEvent({ address: TASK_DISPATCH_ADDRESS, abi: deploy_1.TaskDispatchAbi, eventName: 'TaskRequested', strict: true, onLogs: async (logs) => { for (const log of logs) { try { const { serviceName, serviceVersion, functionName, taskId, isLocal, isDeterministic: _isDeterministic } = log.args; if (!serviceName || !serviceVersion || !functionName || !taskId) { console.warn('Received task event with missing required fields:', log.args); continue; } await processTask(serviceName, serviceVersion, functionName, taskId, isLocal || false); } catch (error) { console.error('Error processing task event:', error); } } }, onError: (error) => { console.error('Event listener error:', error); } }); // In watch mode, listen for task completion events to compare results let taskCompletionUnwatch; if (options.watch) { console.log('👁️ Watch mode: Listening for task completion events...'); // Function to compare local result with onchain result async function compareResults(taskId, onchainResult, eventType) { const localResult = localTaskResults.get(Number(taskId)); if (!localResult) { console.log(`⚠️ No local result found for task ${taskId} to compare`); return; } try { let onchainResultData = ''; let onchainResultType = ''; if (eventType === 'success' && onchainResult.response) { onchainResultData = onchainResult.response.result || ''; onchainResultType = onchainResult.response.resultType || ''; } else { // For failed tasks, we might want to compare differently console.log(`🔍 Task ${taskId} failed onchain, local result was: ${localResult.resultType}`); return; } // Compare the results const resultsMatch = localResult.result === onchainResultData && localResult.resultType === onchainResultType; if (resultsMatch) { console.log(`✅ Task ${taskId}: Local result matches onchain result!`); console.log(` Service: ${localResult.serviceName}@${localResult.serviceVersion}.${localResult.functionName}`); console.log(` Result: ${localResult.resultType} = ${localResult.result.slice(0, 100)}${localResult.result.length > 100 ? '...' : ''}`); } else { console.log(`\x1b[31m❌ MISMATCH: Task ${taskId} local result differs from onchain result!\x1b[0m`); console.log(`\x1b[31m Service: ${localResult.serviceName}@${localResult.serviceVersion}.${localResult.functionName}\x1b[0m`); console.log(`\x1b[31m Local: ${localResult.resultType} = ${localResult.result.slice(0, 100)}${localResult.result.length > 100 ? '...' : ''}\x1b[0m`); console.log(`\x1b[31m Onchain: ${onchainResultType} = ${onchainResultData}\x1b[0m`); } // Clean up stored result localTaskResults.delete(Number(taskId)); } catch (error) { console.error(`❌ Error comparing results for task ${taskId}:`, error); } } // Listen for TaskSuccess events const taskSuccessUnwatch = publicClient.watchContractEvent({ address: TASK_DISPATCH_ADDRESS, abi: deploy_1.TaskDispatchAbi, eventName: 'TaskSuccess', strict: true, onLogs: async (logs) => { for (const log of logs) { try { const { taskId, response } = log.args; if (taskId) { await compareResults(taskId, { response }, 'success'); } } catch (error) { console.error('Error processing TaskSuccess event:', error); } } } }); // Listen for TaskFailed events const taskFailedUnwatch = publicClient.watchContractEvent({ address: TASK_DISPATCH_ADDRESS, abi: deploy_1.TaskDispatchAbi, eventName: 'TaskFailed', strict: true, onLogs: async (logs) => { for (const log of logs) { try { const { taskId } = log.args; if (taskId) { await compareResults(taskId, {}, 'failed'); } } catch (error) { console.error('Error processing TaskFailed event:', error); } } } }); taskCompletionUnwatch = () => { taskSuccessUnwatch(); taskFailedUnwatch(); }; } // Handle graceful shutdown const cleanup = () => { console.log('🛑 Shutting down central operator...'); unwatch(); if (taskCompletionUnwatch) { taskCompletionUnwatch(); } // Kill all worker processes for (const [serviceKey, worker] of packageWorkers) { console.log(`Stopping worker for ${serviceKey}...`); worker.process?.kill('SIGINT'); } }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); console.log('✅ Operator ready - waiting for task events...'); } /** * Creates a worker script for a specific package */ function createPackageWorkerScript(packageName, rpcUrl) { return `// Package worker for ${packageName} import { navs, navsRegistry } from 'navskit'; import { readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import process from 'process'; // Import the package - this will trigger all @navs annotations // and register functions in the navsRegistry import '${packageName}'; // Read package.json to get service name and version const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJsonPath = join(__dirname, 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); const packageLockJsonPath = join(__dirname, 'package-lock.json'); const packageLockJson = JSON.parse(readFileSync(packageLockJsonPath, 'utf8')); const targetPackageVersion = packageLockJson.packages['node_modules/' + '${packageName}'].version; // Get service name and version from package.json const serviceName = '${packageName}'; const serviceVersion = packageJson.version || '1.0.0'; // Get configuration const avsAddress = '${deploy_1.Addresses["11155111"].AVS}'; const reexecutionEndpoint = process.env.REEXECUTION_ENDPOINT || '${deploy_1.Addresses["84532"].TaskDispatch}'; // Log registered NAVS functions console.log(\`📦 Worker[v\${serviceVersion}] started for: \${serviceName}@\${targetPackageVersion}\`); console.log('Registered NAVS functions:'); for (const [name, info] of Object.entries(navsRegistry)) { console.log(\` - \${name} (\${info.serviceName})\${info.consensus ? '*' : ''}\`); } if (Object.keys(navsRegistry).length == 0) { console.error('This package exposes no @navs functions. Exiting...') process.exit(1); } console.log(\`🌐 RPC URL: ${rpcUrl}\`); console.log(\`📍 AVS Contract: \${avsAddress}\`); console.log(\`🔄 Reexecution Endpoint: \${reexecutionEndpoint}\`); console.log(\`📤 Running in JSON output mode - will output results to stdout\`); // Initialize NAVS client with both serviceName and serviceVersion const navsClient = navs({ serviceName, serviceVersion, l2rpcUrl: '${rpcUrl}', avs: avsAddress, reexecutionEndpoint }); // Run the worker in JSON output mode (calls main() which outputs JSON) navsClient.main().catch(error => { console.error(\`Worker failed for \${serviceName}@\${serviceVersion}:\`, error); process.exit(1); }); `; } /** * Runs the central NAVS operator that listens for all tasks */ async function runOperator(options) { try { console.log('Starting central operator...'); // Track package workers for cleanup const packageWorkers = new Map(); // Define the package request handler const handlePackageRequest = async (serviceName, serviceVersion, isLocal = false) => { const serviceKey = `${serviceName}@${serviceVersion}`; console.log(`🔧 Preparing package: ${serviceKey} ${isLocal ? '[LOCAL]' : ''}`); const workerDir = await prepareNavsPackage(serviceName, serviceVersion, options, isLocal); packageWorkers.set(serviceKey, { packageName: serviceName, operatorDir: workerDir }); return workerDir; }; // Run the central operator directly in this process await runCentralOperator(options, handlePackageRequest); } catch (error) { console.error('Failed to run central operator:', error); throw error; } }