navskit
Version:
Deploy TypeScript logic on Ethereum. Includes core library, CLI tools, and utilities.
671 lines (660 loc) • 31.1 kB
JavaScript
;
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;
}
}