UNPKG

@nori-zk/proof-conversion

Version:

Verifying zkVM proofs inside o1js circuits, to generate Mina compatible proof

440 lines 19.2 kB
import * as fs from 'fs'; import * as path from 'path'; import { Logger } from 'esm-iso-logger'; import { Command } from 'commander'; import { LogPrinter } from 'esm-iso-logger'; import { fileURLToPath } from 'url'; import { performSp1Plonk } from '../api/sp1/plonk.js'; import { performSp1Groth16 } from '../api/sp1/groth16.js'; import { performRisc0Groth16 } from '../api/risc0/groth16.js'; import { assertExactStructure } from '../api/validation/validation.js'; import { describeSchema, } from '../api/validation/guards/core.js'; import { performSnarkjsGroth16 } from '../api/snarkjs/groth16.js'; import { ComputationalPlanExecutor } from '../compute/executor.js'; new LogPrinter('NoriProofConverter'); const logger = new Logger('CLI'); const MAX_PROCESSES = parseInt(process.env.MAX_PROCESSES || '1', 10); const executor = new ComputationalPlanExecutor(MAX_PROCESSES); // Helper to execute a specific command with proper type narrowing // By making this generic over K and extracting the input type, we get proper narrowing async function executeCommand(key, executor, input) { const fn = commandMap[key]; // TODO: Fix TypeScript union type narrowing issue - for now we cast the function since input is validated by assertExactStructure return fn(executor, input); } // registry of decorated API functions (must be decorated by ApiMethod) const commandMap = { sp1Plonk: performSp1Plonk, risc0Groth16: performRisc0Groth16, sp1Groth16: performSp1Groth16, snarkjsGroth16: performSnarkjsGroth16, }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const packageJsonPath = path.resolve(__dirname, '..', '..', '..', 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); const version = packageJson.version; // Write output JSON file. If inputFileHint provided, use it to name output, else use commandName. function writeJsonFile(filePathHint, commandName, resultStr) { const baseHint = filePathHint ? path.basename(filePathHint) : commandName; const baseNoExt = baseHint.toLowerCase().endsWith('.json') ? baseHint.slice(0, -5) : baseHint; const outDir = filePathHint ? path.dirname(filePathHint) : process.cwd(); const outPath = path.join(outDir, `${baseNoExt}.${commandName}.json`); fs.writeFileSync(outPath, resultStr); return outPath; } // Strict: read a path as JSON file, or print help + error and exit function readFileStrict(p, commandName, keyHint) { if (!fs.existsSync(p) || !fs.statSync(p).isFile()) { const mode = keyHint ? 'Args-mode' : 'Object-mode'; const argInfo = keyHint ? ` for argument '${keyHint}':` : ':'; logger.error(`${mode}: file does not exist${argInfo}. Printing usage information:`); describeCommand(commandName); logger.error(`Failed to read file${argInfo}`); logger.error(` File: ${p}${keyHint ? ` (expected ${keyHint}.json)` : ''}`); logger.error(` Reason: File does not exist or is not a file.`); logger.error(''); logger.fatal(`Due to the file read error running '${commandName}' in '${mode}' cannot continue`); process.exit(1); } try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { const error = e; const mode = keyHint ? 'Args-mode' : 'Object-mode'; const argInfo = keyHint ? ` for argument '${keyHint}'` : ''; logger.error(`${mode}: invalid JSON${argInfo}. Printing usage information:`); describeCommand(commandName); logger.error(`Failed to parse JSON from file${argInfo}`); logger.error(` File: ${p}${keyHint ? ` (expected ${keyHint}.json)` : ''}`); logger.error(` Reason: ${error.message ?? error}`); logger.error(''); logger.fatal(`Due to the JSON parse error running '${commandName}' in '${mode}' cannot continue`); process.exit(1); } } // --- help utilities using metadata --- function summariseCommandMetadata(name, fn) { const supportsArgs = typeof fn?.fromArgs === 'function'; logger.debug(`[${name}] supportsArgs: ${supportsArgs}, fromArgs type: ${typeof fn?.fromArgs}`); if (supportsArgs && typeof fn.fromArgs === 'function') { const fromArgsWithKeys = fn.fromArgs; logger.debug(`[${name}] fromArgs.keys: ${JSON.stringify(fromArgsWithKeys.keys)}`); } const argsMeta = supportsArgs && typeof fn.fromArgs === 'function' ? fn.fromArgs.keys || null : null; logger.debug(`[${name}] argsMeta: ${JSON.stringify(argsMeta)}`); return { name, supportsArgs, argsMeta }; } // Helper to print object-mode schema function printObjectModeSchema(schema) { logger.log(''); logger.log(`Provide one JSON file path arguments`); logger.log(''); logger.log('Expected schemas for the file:'); const objectSchema = describeSchema(schema); const schemaLines = JSON.stringify(objectSchema, null, 2).split('\n'); schemaLines.forEach((line) => logger.log(` ${line}`)); logger.log(''); } // Helper to print args-mode schemas for each file function printArgsModeSchemas(argsKeys, schema, filePaths) { logger.log(''); logger.log(`Provide '${argsKeys.length}' JSON file path arguments, in order: ${argsKeys.join(', ')}`); logger.log(''); logger.log('Expected schemas for each file:'); for (let i = 0; i < argsKeys.length; i++) { const key = argsKeys[i]; const filePath = filePaths ? filePaths[i] : undefined; if (key in schema) { logger.log(''); if (filePath) { logger.log(` ${filePath} (${key}.json):`); } else { logger.log(` ${key}.json:`); } const keySchema = describeSchema(schema[key]); const schemaLines = JSON.stringify(keySchema, null, 2).split('\n'); schemaLines.forEach((line) => logger.log(` ${line}`)); } } logger.log(''); } function buildHelpAfterText() { const lines = []; lines.push('Available commands and metadata:'); for (const name of Object.keys(commandMap)) { const fn = commandMap[name]; const meta = summariseCommandMetadata(name, fn); const parts = []; parts.push(meta.supportsArgs ? `args(files): [${(meta.argsMeta || []).join(', ')}]` : 'args: (no)'); // Object mode is always supported (uses schema directly) parts.push('object: (yes)'); lines.push(` - ${name} ${parts.join(' | ')}`); } lines.push('\nRun `describe <command>` for more details and examples.'); lines.forEach((line) => logger.log(line)); return ''; } function describeCommand(commandName) { const fn = commandMap[commandName]; if (!fn) { logger.error(`\nCommand '${commandName}' not found. Available: ${Object.keys(commandMap).join(', ')}\n`); return; } const meta = summariseCommandMetadata(commandName, fn); logger.log(''); logger.log(`=== ${commandName} ===`); logger.log(''); // Print object-mode schema (always supported) logger.log('Object-mode schema:'); if (typeof fn === 'function' && 'schema' in fn && fn.schema) { printObjectModeSchema(fn.schema); } else { logger.log(' (no schema available)'); } logger.log(''); logger.log('Object-mode usage:'); logger.log(` $ nori-proof-converter ${commandName} path/to/${commandName}_input.json`); logger.log(''); // Print args-mode schema if supported if (meta.supportsArgs && meta.argsMeta !== null && typeof fn === 'function' && 'schema' in fn && fn.schema) { logger.log('Args-mode (file-per-key) schemas:'); printArgsModeSchemas(meta.argsMeta, fn.schema); logger.log('Args-mode usage:'); const argsExamplePaths = meta.argsMeta .map((arg) => `path/to/${arg}.json`) .join(' '); logger.log(` $ nori-proof-converter ${commandName} ${argsExamplePaths}`); } else { logger.log('Args-mode: not supported'); } } const program = new Command(); program .name(Object.keys(packageJson.bin)[0]) .description(packageJson.description) .version(packageJson.version); // append metadata summary to help text program.addHelpText('after', () => buildHelpAfterText()); // describe subcommand (same output as describeCommand, but callable) program .command('describe [command]') .description('Show detailed metadata and examples for a command') .action((commandName) => { logger.info(`Running action for command='describe', args=[${commandName ? `'${commandName}'` : ''}]`); if (!commandName) { logger.error(`describe: missing required argument 'command'. Printing usage information:`); logger.log(''); logger.log('Usage:'); logger.log(' $ nori-proof-converter describe <command>'); logger.log(''); logger.log('Available commands:'); Object.keys(commandMap).forEach((name) => { logger.log(` - ${name}`); }); logger.log(''); logger.fatal(`Due to missing required argument running 'describe' cannot continue`); process.exit(1); } try { describeCommand(commandName); process.exit(0); } catch (e) { const error = e; logger.fatal('Failed to print description:'); logger.fatal(error.stack); process.exit(1); } }); // ---------- main command: accept zero-or-more args ---------- program .argument('<command>', 'command to execute (e.g. sp1Plonk or risc0Groth16)') .argument('[args...]', 'optional arguments: single JSON file (object-mode) or multiple file paths (args-mode)') .action(async (commandName, args = []) => { logger.info(`Running action for command='${commandName}', args=${JSON.stringify(args)}`); const fn = commandMap[commandName]; if (!fn) { logger.fatal(`Command '${commandName}' not found. Available: ${Object.keys(commandMap).join(', ')}`); process.exit(1); } // If no args provided, print command-specific help/metadata and exit if (!Array.isArray(args) || args.length === 0) { logger.info(`No args provided for '${commandName}' — printing usage:`); describeCommand(commandName); process.exit(0); } // determine mode and validate const mode = args.length === 1 ? 'object' : 'args'; logger.debug(`selected mode='${mode}'`); const outputNameHint = args[0]; if (mode === 'object') { // object-mode: single arg MUST be a file path (no inline JSON) const obj = readFileStrict(args[0], commandName); if (typeof obj !== 'object' || obj === null) { logger.fatal('Object-mode requires a JSON object file.'); process.exit(1); } // Validate the object against the schema if (typeof fn === 'function' && 'schema' in fn && fn.schema) { // Validation try-catch try { assertExactStructure(obj, fn.schema, `Object-mode input ${args[0]}`); } catch (e) { const error = e; // Validation error - print schema logger.error(`Object-mode validation failed for '${commandName}'.`); printObjectModeSchema(fn.schema); logger.error('Validation errors:'); logger.error(''); // Parse and format errors logger.error(` ${args[0]}:`); const errorLines = error.message .split('\n') .filter((line) => line.trim() && !line.includes('validation failed:')); errorLines.forEach((line) => logger.error(` ${line}`)); logger.error(''); logger.fatal(`Due to the validation issues running '${commandName}' in '${mode}' mode cannot continue`); process.exit(1); } // Execution try-catch try { const result = await executeCommand(commandName, executor, obj); const resultStr = JSON.stringify(result, null, 2); const outputFilePath = writeJsonFile(outputNameHint, commandName, resultStr); logger.info(`Wrote result of command '${commandName}' to disk: '${outputFilePath}'`); process.exit(0); } catch (e) { const error = e; logger.fatal(`Error executing command '${commandName}': ${error.message ?? error}`); logger.fatal(error.stack); process.exit(1); } } else { logger.fatal(`Command '${commandName}' was not a compatible ApiMethod. Raise an issue in our GitHub!`); process.exit(1); } } else { // args-mode: require each arg to be a file path containing JSON if (fn.fromArgs === false || typeof fn.fromArgs !== 'function') { logger.fatal(`Command '${commandName}' does not support args-mode. Please provide a single json file path argument.`); process.exit(1); } // Get the keys from fromArgs.keys const fromArgsWithKeys = fn.fromArgs; const argsKeys = fromArgsWithKeys.keys; if (!Array.isArray(argsKeys)) { logger.fatal(`Command '${commandName}' missing keys metadata for args-mode validation. Please raise an issue on Github!`); process.exit(1); } if (args.length !== argsKeys.length) { logger.error(`Args-mode requires ${argsKeys.length} file arguments. Received ${args.length}.`); // Print schemas for each expected file const schemaObj = fn.schema; printArgsModeSchemas(argsKeys, schemaObj); logger.error('Usage:'); const argsExamplePaths = argsKeys .map((arg) => `path/to/${arg}.json`) .join(' '); logger.error(` $ nori-proof-converter ${commandName} ${argsExamplePaths}`); logger.error(''); logger.fatal(`Due to wrong number of arguments running '${commandName}' in '${mode}' mode cannot continue`); process.exit(1); } // Build and validate in the same scope if (typeof fn === 'function' && 'schema' in fn && fn.schema) { // Read each file one by one and build object const constructedObj = {}; for (let i = 0; i < argsKeys.length; i++) { const key = argsKeys[i]; const filePath = args[i]; constructedObj[key] = readFileStrict(filePath, commandName, key); } // Validation try-catch try { assertExactStructure(constructedObj, fn.schema, `Args-mode input (${argsKeys.join(', ')})`); } catch (e) { const error = e; // Validation error - print schemas for each file logger.error(`Args-mode validation failed for '${commandName}'.`); const schemaObj = fn.schema; printArgsModeSchemas(argsKeys, schemaObj, args); logger.error('Validation errors:'); logger.error(''); // Parse and group errors by file const errorsByFile = {}; const errorLines = error.message .split('\n') .filter((line) => line.trim() && !line.includes('validation failed:')); for (const errorLine of errorLines) { // Extract the top-level key from the path // Match patterns like: "key[...]", "key:", "key should have type" const match = errorLine.match(/^\s*(\w+)(?:\[|:| )/); if (match) { const fileKey = match[1]; if (!errorsByFile[fileKey]) { errorsByFile[fileKey] = []; } errorsByFile[fileKey].push(errorLine); } } // Print errors grouped by file for (let i = 0; i < argsKeys.length; i++) { const key = argsKeys[i]; const filePath = args[i]; const fileErrors = errorsByFile[key]; if (fileErrors && fileErrors.length > 0) { logger.error(` ${filePath} (${key}.json):`); fileErrors.forEach((err) => { logger.error(` ${err}`); }); logger.error(''); } } logger.fatal(`Due to the validation issues running '${commandName}' in '${mode}' mode cannot continue`); process.exit(1); } // Execution try-catch try { const result = await executeCommand(commandName, executor, constructedObj); const resultStr = JSON.stringify(result, null, 2); const outputFilePath = writeJsonFile(outputNameHint, commandName, resultStr); logger.info(`Wrote result of command ${commandName} to disk: '${outputFilePath}'`); process.exit(0); } catch (e) { const error = e; logger.fatal(`Error executing command '${commandName}': ${error.message ?? error}`); logger.fatal(error.stack); process.exit(1); } } else { logger.fatal(`Command '${commandName}' was not a compatible ApiMethod. Raise an issue in our GitHub!`); process.exit(1); } } }); // show help when no args at all if (process.argv.length <= 2) { logger.log(program.helpInformation()); logger.log(`Version: ${version}`); logger.log(`Available commands: ${Object.keys(commandMap).join(', ')}`); process.exit(0); } // Override exit try { program.exitOverride((err) => { if (err.exitCode === 0) { process.exit(0); } logger.log(program.helpInformation()); logger.log(`Version: ${version}`); logger.log(`Available commands: ${Object.keys(commandMap).join(', ')}`); logger.fatal(err.stack); process.exit(1); }); program.parse(process.argv); } catch (e) { const error = e; logger.log(program.helpInformation()); logger.log(`Version: ${version}`); logger.log(`Available commands: ${Object.keys(commandMap).join(', ')}`); logger.fatal(error.stack); process.exit(1); } // TODO Override help... How?? // Ctrl+C handling process.on('SIGINT', async () => { try { logger.warn('Process interrupted by user. Cleaning up.'); await executor.terminate(); logger.fatal('Cleanup finished. Exiting now.'); process.exit(0); } finally { logger.fatal('Cleanup failed. Exiting now.'); process.exit(1); } }); //# sourceMappingURL=cli.js.map