UNPKG

zkapp-cli

Version:

CLI to create zkApps (zero-knowledge apps) for Mina Protocol

1,464 lines (1,396 loc) 44.6 kB
import chalk from 'chalk'; import createDebug from 'debug'; import decompress from 'decompress'; import enquirer from 'enquirer'; import fs from 'fs-extra'; import dns from 'node:dns'; import path from 'node:path'; import opener from 'opener'; import ora from 'ora'; import semver from 'semver'; import shell from 'shelljs'; import { getBorderCharacters, table } from 'table'; import Constants from './constants.js'; import { isDirEmpty, step } from './helpers.js'; import { checkLocalPortsAvailability } from './network-helpers.js'; import { sleep } from './time-helpers.js'; // Module external API export { lightnetExplorer, lightnetFollowLogs, lightnetSaveLogs, lightnetStart, lightnetStatus, lightnetStop, }; // Module internal API (exported for testing purposes) export { buildDebugLogger, checkDockerEngineAvailability, copyContainerLogToHost, dockerContainerIdMatchesConfig, downloadExplorerRelease, executeCmd, fetchExplorerReleases, generateLogsDirPath, getAvailableDockerEngineResources, getBlockchainNetworkReadinessMaxAttempts, getCurrentExplorerVersion, getDockerContainerId, getDockerContainerStartupCmdPorts, getDockerContainerState, getDockerContainerVolume, getLocalExplorerVersions, getLogFilePaths, getProcessToLogFileMapping, getRequiredDockerContainerPorts, getSystemQuotes, handleDockerContainerPresence, handleExplorerReleasePresence, handleStartCommandChecks, handleStopCommandChecks, handleYesNoConfirmation, isEnoughDockerEngineResourcesAvailable, launchExplorer, printBlockchainNetworkProperties, printCmdDebugLog, printDockerContainerProcessesLogPaths, printExplorerVersions, printExtendedDockerContainerState, printUsefulUrls, printZkAppSnippet, processArchiveNodeApiLogs, processMultiNodeLogs, processSingleNodeLogs, promptForDockerContainerProcess, removeDanglingDockerImages, removeDockerContainer, removeDockerVolume, saveDockerContainerProcessesLogs, secondsToHms, shellExec, stopDockerContainer, streamDockerContainerFileContent, updateCurrentExplorerVersion, waitForBlockchainNetworkReadiness, }; dns.setDefaultResultOrder('ipv4first'); const debug = createDebug('zk:lightnet'); const debugLog = buildDebugLogger(); const lightnetConfigFile = path.resolve( `${Constants.lightnetWorkDir}/config.json` ); const lightnetLogsDir = path.resolve(`${Constants.lightnetWorkDir}/logs`); const lightnetExplorerDir = path.resolve( `${Constants.lightnetWorkDir}/explorer` ); const lightnetExplorerConfigFile = path.resolve( `${lightnetExplorerDir}/config.json` ); const lightnetDockerContainerName = 'mina-local-lightnet'; const archiveNodeApiProcessName = 'Archive-Node-API application'; const minaArchiveProcessName = 'Mina Archive process'; const multiPurposeMinaDaemonProcessName = 'Mina multi-purpose Daemon'; const DockerContainerState = { RUNNING: 'running', NOT_FOUND: 'not-found', }; const ContainerLogFilesPrefix = { SINGLE_NODE: '/root', MULTI_NODE: '/root/.mina-network/mina-local-network-2-1-1/nodes', }; const commonServicesPorts = [8080, 8181]; const archivePorts = [5432, 8282]; const singleNodePorts = [3085]; const multiNodePorts = [4001, 4006, 5001, 6001]; const { quotes, escapeQuotes } = getSystemQuotes(); /** * Starts the lightweight Mina blockchain network Docker container. * @param {object} argv - The arguments object provided by yargs. * @param {string} argv.mode - The mode to start the lightnet in. * @param {string} argv.type - The type of lightnet to start. * @param {string} argv.proofLevel - The proof level to use. * @param {string} argv.minaBranch - The Mina branch to use. * @param {boolean} argv.archive - Whether to start the Mina Archive process and the Archive-Node-API application. * @param {boolean} argv.sync - Whether to wait for the network to sync. * @param {boolean} argv.pull - Whether to pull the latest version of the Docker image from the Docker Hub. * @param {string} argv.minaLogLevel - Mina processes logging level to use. * @param {number} argv.slotTime - The slot time for block production to use. * @returns {Promise<void>} */ async function lightnetStart({ mode, type, proofLevel, minaBranch, archive, sync, pull, minaLogLevel, slotTime, }) { let containerId = null; let containerVolume = null; await checkDockerEngineAvailability(); await step('Checking prerequisites', async () => { await handleStartCommandChecks(lightnetDockerContainerName, mode, archive); }); await step( 'Stopping and removing the existing Docker container', async () => { await stopDockerContainer(lightnetDockerContainerName); await removeDockerContainer(lightnetDockerContainerName); } ); if (pull) { await step('Pulling the corresponding Docker image', async () => { await executeCmd( `docker pull o1labs/mina-local-network:${minaBranch}-latest-${ type === 'fast' ? 'lightnet' : 'devnet' }` ); await removeDanglingDockerImages(); }); } await step( 'Starting the lightweight Mina blockchain network Docker container', async () => { await executeCmd( `docker run --name ${lightnetDockerContainerName} --pull=missing -id ` + `--env NETWORK_TYPE="${mode}" ` + `--env PROOF_LEVEL="${proofLevel}" ` + `--env LOG_LEVEL="${minaLogLevel}" ` + `--env RUN_ARCHIVE_NODE="${archive}" ` + `--env SLOT_TIME="${slotTime}" ` + getDockerContainerStartupCmdPorts(mode, archive) + `o1labs/mina-local-network:${minaBranch}-latest-${ type === 'fast' ? 'lightnet' : 'devnet' }` ); containerId = await getDockerContainerId(lightnetDockerContainerName); containerVolume = await getDockerContainerVolume( lightnetDockerContainerName ); } ); await step('Preserving the network configuration', async () => { const data = { containerId, containerVolume, mode, type, proofLevel, minaBranch, archive, sync, pull, minaLogLevel, slotTime, }; debugLog( 'Updating file %s with JSON content: %O', lightnetConfigFile, data ); fs.outputJsonSync(lightnetConfigFile, data, { spaces: 2, flag: 'w' }); }); if (sync) { let runTime = null; await step('Waiting for the blockchain network readiness', async () => { const startTime = performance.now(); await waitForBlockchainNetworkReadiness(mode); runTime = chalk.green.bold( secondsToHms(Math.round((performance.now() - startTime) / 1000)) ); }); const statusColored = chalk.green.bold('is ready'); console.log(`\nBlockchain network ${statusColored} in ${runTime}.`); await lightnetStatus(true); } else { const statusColored = chalk.green.bold('is running'); console.log( `\nThe lightweight Mina blockchain network Docker container ${statusColored}.` + '\nPlease check the network readiness a bit later by executing:\n\n' + chalk.green.bold('zk lightnet status') + '\n' ); } } /** * Stops the lightweight Mina blockchain network Docker container. * @param {object} argv - The arguments object provided by yargs. * @param {boolean} argv.saveLogs - Whether to save the Docker container processes logs to the host file system. * @param {boolean} argv.cleanUp - Whether to perform the clean up. * @returns {Promise<void>} */ async function lightnetStop({ saveLogs, cleanUp }) { let logsDir = null; await checkDockerEngineAvailability(); await step('Checking prerequisites', async () => { await handleStopCommandChecks(lightnetDockerContainerName); }); await step( 'Stopping the lightweight Mina blockchain network Docker container', async () => { await stopDockerContainer(lightnetDockerContainerName); } ); if ( saveLogs && fs.existsSync(lightnetConfigFile) && DockerContainerState.NOT_FOUND !== (await getDockerContainerState(lightnetDockerContainerName)) ) { await step('Preserving the Docker container processes logs', async () => { logsDir = await saveDockerContainerProcessesLogs(); }); } if (cleanUp) { await step( 'Cleaning up' + '\n - Docker container' + '\n - Dangling Docker images' + '\n - Docker volume' + '\n - Blockchain network configuration', async () => { await removeDockerContainer(lightnetDockerContainerName); await removeDanglingDockerImages(); if (fs.existsSync(lightnetConfigFile)) { await removeDockerVolume( fs.readJSONSync(lightnetConfigFile).containerVolume ); } debugLog('Removing file or dir %s\n\n\n\n', lightnetConfigFile); fs.removeSync(lightnetConfigFile); } ); } if (logsDir) { const boldLogs = chalk.reset.bold('logs'); console.log( `\nThe Docker container processes ${boldLogs} can be found at the following path:\n\n` + chalk.green.bold(logsDir) + '\n' ); console.log('Done\n'); } else { if (fs.existsSync(lightnetLogsDir) && isDirEmpty(lightnetLogsDir)) { debugLog('Removing file or dir %s\n\n\n\n', lightnetLogsDir); fs.removeSync(lightnetLogsDir); } console.log('\nDone\n'); } } /** * Gets the lightweight Mina blockchain network status. * @param {boolean} preventDockerEngineAvailabilityCheck - Whether to prevent the Docker Engine availability check. * @returns {Promise<void>} */ async function lightnetStatus(preventDockerEngineAvailabilityCheck = false) { if (!preventDockerEngineAvailabilityCheck) { await checkDockerEngineAvailability(); } const containerState = await getDockerContainerState( lightnetDockerContainerName ); if (DockerContainerState.NOT_FOUND === containerState) { console.log( chalk.red( '\nThe lightweight Mina blockchain network Docker container does not exist!' ) ); shell.exit(1); } console.log(chalk.reset.bold('\nLightweight Mina blockchain network')); console.log( chalk.reset( '\nMore information can be found at:\nhttps://docs.minaprotocol.com/zkapps/testing-zkapps-lightnet\n' ) ); if ( DockerContainerState.RUNNING === containerState && fs.existsSync(lightnetConfigFile) ) { printUsefulUrls(); printDockerContainerProcessesLogPaths(); await printBlockchainNetworkProperties(); printZkAppSnippet(); await printExtendedDockerContainerState(lightnetDockerContainerName); } else { console.log( chalk.yellow.bold( '\nWarning:\nThe lightweight Mina blockchain network Docker container is either ' + '\nnot running or it was created outside of this application.' + '\nOnly limited information is available.' ) ); await printExtendedDockerContainerState(lightnetDockerContainerName); } } /** * Saves the lightweight Mina blockchain network Docker container processes logs to the host file system. * @returns {Promise<void>} */ async function lightnetSaveLogs() { let logsDir = null; await checkDockerEngineAvailability(); if ( fs.existsSync(lightnetConfigFile) && DockerContainerState.NOT_FOUND !== (await getDockerContainerState(lightnetDockerContainerName)) ) { await step('Preserving the Docker container processes logs', async () => { logsDir = await saveDockerContainerProcessesLogs(); }); if (logsDir) { const boldLogs = chalk.reset.bold('logs'); console.log( `\nThe Docker container processes ${boldLogs} were preserved at the following path:\n\n` + chalk.green.bold(logsDir) + '\n' ); } else { console.log( chalk.red( '\nIssue happened during the Docker container processes logs preservation!' ) ); shell.exit(1); } } else { console.log( chalk.red( '\nIt is impossible to preserve the logs at the moment!' + '\nPlease ensure that the lightweight Mina blockchain network Docker container exists, then try again.' ) ); shell.exit(1); } } /** * Follows one of the lightweight Mina blockchain network Docker container processes logs. * @param {object} argv - The arguments object provided by yargs. * @param {string} argv.process - The name of the Docker container process to follow the logs of. * @returns {Promise<void>} */ async function lightnetFollowLogs({ process }) { await checkDockerEngineAvailability(); const isDockerContainerRunning = fs.existsSync(lightnetConfigFile) && DockerContainerState.RUNNING === (await getDockerContainerState(lightnetDockerContainerName)); if (!isDockerContainerRunning) { console.log( chalk.red( '\nIt is impossible to follow the logs at the moment!' + '\nPlease ensure that the lightweight Mina blockchain network Docker container is up and running, then try again.' ) ); shell.exit(1); } const lightnetConfig = fs.readJSONSync(lightnetConfigFile); const processToLogFileMapping = getProcessToLogFileMapping(lightnetConfig); const selectedProcess = process || (await promptForDockerContainerProcess(processToLogFileMapping)); const logFilePath = processToLogFileMapping.get(selectedProcess); await step('Docker container file content streaming', async () => { await streamDockerContainerFileContent( lightnetDockerContainerName, logFilePath ); }); } /** * Launches the lightweight Mina explorer. * @param {object} argv - The arguments object provided by yargs. * @param {string} argv.use - The version of the lightweight Mina explorer to use. * @param {boolean} argv.list - Whether to list the available versions of the lightweight Mina explorer. * @returns {Promise<void>} */ async function lightnetExplorer({ use, list }) { if (list) { await printExplorerVersions(); } else { await launchExplorer(use); } } async function printExplorerVersions() { try { const releasesPrintLimit = 5; const border = getBorderCharacters('norc'); const boldTitle = chalk.reset.bold('Lightweight Mina explorer versions'); const versions = [ [boldTitle, '', ''], ['Version', 'Published on', 'Is in use?'], ]; const releases = await fetchExplorerReleases(); const currentVersion = getCurrentExplorerVersion(); if (!releases || releases.length === 0) { versions.push([ chalk.yellow('No data available yet.\nPlease try again later.'), '', '', ]); console.log( '\n' + table(versions, { border, spanningCells: [ { col: 0, row: 0, colSpan: 3, alignment: 'center' }, { col: 0, row: 2, colSpan: 3, alignment: 'center' }, ], }) ); return; } for (const release of releases.slice(0, releasesPrintLimit)) { versions.push([ chalk.reset.bold(release.name), new Date(release.published_at).toLocaleString(), currentVersion === release.name ? chalk.reset.green.bold('✓ Yes') : '', ]); } console.log( '\n' + table(versions, { border, spanningCells: [{ col: 0, row: 0, colSpan: 3, alignment: 'center' }], }) ); if (releases.length > releasesPrintLimit) { console.log( `Only ${chalk.green.bold( releasesPrintLimit )} most recent versions are shown.` + '\nPlease refer to the GitHub repository for the full list of available versions:' + chalk.green( '\nhttps://github.com/o1-labs/mina-lightweight-explorer/releases' ) ); } } catch (error) { debugLog('%o', error); console.log( chalk.red( '\nIssue happened while fetching the lightweight Mina explorer available versions!' ) ); shell.exit(1); } } async function launchExplorer(use) { try { let useVersion; let release = null; const releases = await fetchExplorerReleases(); if (releases === null) { console.log( chalk.yellow(' ' + 'Attempting to use the latest local version.') ); const localVersions = getLocalExplorerVersions(); if (localVersions.length === 0) { console.log( chalk.red( '\nNo local versions of the lightweight Mina explorer are available. Please check your network connection and try again.' ) ); shell.exit(1); } useVersion = localVersions[0]; } else { if (releases.length === 0) { console.log( chalk.red( '\nNo lightweight Mina explorer versions are available yet. Please try again later.' ) ); shell.exit(1); } useVersion = use === 'latest' ? releases[0].name : use; release = releases.find((release) => release.name === useVersion); } const explorerReleasePath = path.resolve( `${lightnetExplorerDir}/${useVersion}` ); const explorerReleaseIndexFilePath = path.resolve( `${explorerReleasePath}/index.html` ); if (releases && !release) { console.log( chalk.red( `\nThe specified version ("${useVersion}") of the lightweight Mina explorer does not exist or is not available for download.` ) ); shell.exit(1); } await handleExplorerReleasePresence(explorerReleasePath, release); await updateCurrentExplorerVersion(useVersion); await step('Launching the lightweight Mina explorer', async () => { opener(`file://${explorerReleaseIndexFilePath}`); }); console.log( chalk.reset( '\nThe lightweight Mina explorer is available at the following path:' + '\n\n' + chalk.green.bold(explorerReleaseIndexFilePath) + '\n' ) ); } catch (error) { debugLog('%o', error); console.log( chalk.red( '\nIssue happened while launching the lightweight Mina explorer!' ) ); shell.exit(1); } } function getCurrentExplorerVersion() { if (fs.existsSync(lightnetExplorerConfigFile)) { return fs.readJSONSync(lightnetExplorerConfigFile).version; } return null; } async function updateCurrentExplorerVersion(version) { const currentVersion = getCurrentExplorerVersion(); if (currentVersion !== version) { await step( 'Updating the current lightweight Mina explorer version in use', async () => { let explorerConfig = { version }; if (fs.existsSync(lightnetExplorerConfigFile)) { explorerConfig = fs.readJSONSync(lightnetExplorerConfigFile); explorerConfig.version = version; } fs.ensureDirSync(lightnetExplorerDir); fs.outputJsonSync(lightnetExplorerConfigFile, explorerConfig, { spaces: 2, flag: 'w', }); } ); } } async function fetchExplorerReleases() { const stepName = 'Fetching the lightweight Mina explorer releases information...'; const spin = ora({ text: stepName, discardStdin: true, }).start(); try { const explorerReleasesUrl = 'https://api.github.com/repos/o1-labs/mina-lightweight-explorer/releases'; let releases = []; debugLog('URL in use: %s', explorerReleasesUrl); const response = await fetch(explorerReleasesUrl, { method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, }); if (!response.ok) { throw new Error( `Received ${response.status} status code from the GitHub API.` ); } releases = await response.json(); spin.succeed(chalk.green(stepName)); return releases; } catch (error) { spin.warn(chalk.yellow(stepName)); console.log( ' ' + chalk.yellow( 'Warning: Unable to fetch lightweight Mina explorer releases. This may be due to connectivity issues.' ) ); return null; } } async function downloadExplorerRelease(release) { await step( 'Downloading the lightweight Mina explorer release bundle', async () => { debugLog('URL in use: %s', release.zipball_url); const response = await fetch(release.zipball_url); if (!response.ok) { throw new Error( `Received ${response.status} status code from the GitHub API.` ); } const arrayBuffer = await response.arrayBuffer(); fs.ensureDirSync(lightnetExplorerDir); fs.writeFileSync( path.resolve(`${lightnetExplorerDir}/${release.name}.zip`), Buffer.from(arrayBuffer), 'binary' ); } ); } async function handleExplorerReleasePresence(explorerReleasePath, release) { if (!fs.existsSync(explorerReleasePath) && !!release) { const tmpDir = path.resolve(`${explorerReleasePath}-tmp`); await step('Preparing the file-system', async () => { fs.removeSync(explorerReleasePath); fs.ensureDirSync(explorerReleasePath); fs.ensureDirSync(tmpDir); }); await downloadExplorerRelease(release); await step('Unpacking the release bundle', async () => { await decompress(`${explorerReleasePath}.zip`, tmpDir); }); await step('Restructuring the file-system', async () => { const extractedDir = fs.readdirSync(tmpDir)[0]; fs.moveSync( path.resolve(`${tmpDir}/${extractedDir}`), path.resolve(explorerReleasePath), { overwrite: true } ); }); await step('Cleaning up', async () => { fs.removeSync(`${explorerReleasePath}.zip`); fs.removeSync(tmpDir); }); } } function getLocalExplorerVersions() { if (!fs.existsSync(lightnetExplorerDir)) { return []; } const versions = fs.readdirSync(lightnetExplorerDir).filter((file) => { const filePath = path.join(lightnetExplorerDir, file); return ( fs.statSync(filePath).isDirectory() && file.match(/^v\d+\.\d+\.\d+$/) ); }); return versions.sort((a, b) => { return semver.compare(b, a); }); } function getProcessToLogFileMapping({ mode, archive }) { let mapping = new Map(Constants.lightnetProcessToLogFileMapping); if (mode === 'single-node') { mapping = new Map([...mapping].slice(0, 3)); mapping.forEach((value, key) => { mapping.set( key, `${ContainerLogFilesPrefix.SINGLE_NODE}/${value.split(',')[0]}` ); }); } else { mapping.delete(multiPurposeMinaDaemonProcessName); mapping.forEach((value, key) => { const logFilePaths = value.split(','); mapping.set( key, `${ archiveNodeApiProcessName === key ? ContainerLogFilesPrefix.SINGLE_NODE : ContainerLogFilesPrefix.MULTI_NODE }/${logFilePaths.length === 1 ? logFilePaths[0] : logFilePaths[1]}` ); }); } if (!archive) { mapping.delete(archiveNodeApiProcessName); mapping.delete(minaArchiveProcessName); } return mapping; } async function promptForDockerContainerProcess(processToLogFileMapping) { /* istanbul ignore next */ const response = await enquirer.prompt({ type: 'select', name: 'selectedProcess', choices: [...processToLogFileMapping.keys()], message: () => { return chalk.reset( 'Please select the Docker container process to follow the logs of' ); }, prefix: (state) => { // Shows a cyan question mark when not submitted. // Shows a green check mark if submitted. // Shows a red "x" if ctrl+C is pressed (default is a magenta). if (!state.submitted) return `\n${state.symbols.question}`; return !state.cancelled ? state.symbols.check : chalk.red(state.symbols.cross); }, result: (val) => val.trim(), }); return response.selectedProcess; } async function checkDockerEngineAvailability() { await step('Checking the Docker Engine availability', async () => { if (!shell.which('docker')) { console.log( chalk.red( '\n\nPlease ensure that Docker Engine is installed, then try again.' + '\nSee https://docs.docker.com/engine/install/ for more information.' ) ); shell.exit(1); } const { code } = await executeCmd('docker ps -a'); if (code !== 0) { console.log( chalk.red( '\n\nPlease ensure that Docker Engine is running, then try again.' ) ); shell.exit(1); } }); } async function handleStartCommandChecks(containerName, mode, archive) { const containerState = await getDockerContainerState(containerName); if ( DockerContainerState.RUNNING === containerState && fs.existsSync(lightnetConfigFile) ) { console.log( chalk.red( '\n\nThe lightweight Mina blockchain network is already running!' ) ); shell.exit(1); } else if ( DockerContainerState.NOT_FOUND !== containerState && (!fs.existsSync(lightnetConfigFile) || !(await dockerContainerIdMatchesConfig(containerName))) ) { await handleDockerContainerPresence(); } const requiredDockerContainerPorts = getRequiredDockerContainerPorts( mode, archive ); debugLog( 'Checking the following ports availability: %o', requiredDockerContainerPorts ); const result = await checkLocalPortsAvailability( requiredDockerContainerPorts ); if (result.error) { console.log(chalk.red(`\n\n${result.message}`)); shell.exit(1); } const { error: resourcesError, message: resourcesErrorMessage } = await isEnoughDockerEngineResourcesAvailable(mode, archive); if (resourcesError) { await handleYesNoConfirmation(resourcesErrorMessage); } } async function handleStopCommandChecks(containerName) { if ( DockerContainerState.NOT_FOUND !== (await getDockerContainerState(containerName)) && (!fs.existsSync(lightnetConfigFile) || !(await dockerContainerIdMatchesConfig(containerName))) ) { await handleDockerContainerPresence(); } } async function handleDockerContainerPresence() { await handleYesNoConfirmation( 'The lightweight Mina blockchain network Docker container already exists and it was created outside of this application.' ); } async function handleYesNoConfirmation(message) { /* istanbul ignore next */ const res = await enquirer.prompt({ type: 'select', name: 'proceed', choices: ['Yes', 'No'], message: () => { return chalk.reset( chalk.bold.yellow(message) + chalk.reset('\nDo you want to proceed anyway?') ); }, prefix: (state) => { if (!state.submitted) return `\n${state.symbols.question}`; return !state.cancelled ? state.symbols.check : chalk.red(state.symbols.cross); }, result: (val) => val.trim().toLowerCase(), }); if (res.proceed === 'no') { shell.exit(0); } } async function getDockerContainerState(containerName) { const { stdout } = await executeCmd( `docker inspect -f ${quotes}{{.State.Status}}${quotes} ${containerName}` ); return stdout.trim() === '' ? DockerContainerState.NOT_FOUND : stdout.trim(); } async function getDockerContainerId(containerName) { const { stdout } = await executeCmd( `docker inspect -f ${quotes}{{.Id}}${quotes} ${containerName}` ); return stdout.trim(); } async function getDockerContainerVolume(containerName) { const { stdout } = await executeCmd( `docker inspect -f ${quotes}{{range .Mounts}}{{if eq .Type ${escapeQuotes}"volume${escapeQuotes}"}}{{.Name}}{{end}}{{end}}${quotes} ${containerName}` ); return stdout.trim(); } async function dockerContainerIdMatchesConfig(containerName) { const actualId = await getDockerContainerId(containerName); const expectedId = fs.readJSONSync(lightnetConfigFile).containerId; return actualId === expectedId; } async function stopDockerContainer(containerName) { await executeCmd(`docker stop ${containerName}`); } async function removeDockerContainer(containerName) { await executeCmd(`docker rm ${containerName}`); } async function removeDockerVolume(volume) { await executeCmd(`docker volume rm ${volume}`); } async function removeDanglingDockerImages() { await executeCmd('docker image prune -f --filter "dangling=true"'); } async function getAvailableDockerEngineResources() { const { stdout } = await executeCmd( "docker info -f '{{.NCPU}}:{{.MemTotal}}'" ); const [cpu, memory] = stdout.trim().split(':'); return { cpu: parseInt(cpu), memoryGB: parseFloat((parseInt(memory) / 1024 / 1024 / 1024).toFixed(2)), }; } async function streamDockerContainerFileContent(containerName, filePath) { try { const border = getBorderCharacters('norc'); console.log( '\n' + table( [[chalk.reset('Use Ctrl+C to stop the file content streaming.')]], { border, } ) ); debugLog('Streaming the Docker container file %s content...', filePath); await executeCmd( `docker exec ${containerName} tail -n 50 -f ${filePath}`, false ); } catch (error) { debugLog('%o', error); console.log( chalk.red( '\nIssue happened while streaming the Docker container file content!' ) ); shell.exit(1); } } async function saveDockerContainerProcessesLogs() { const logsDir = generateLogsDirPath(); try { fs.ensureDirSync(logsDir); const { mode, archive } = fs.readJSONSync(lightnetConfigFile); const logFilePaths = getLogFilePaths(mode); if (mode === 'single-node') { await processSingleNodeLogs(logFilePaths, logsDir); } else { if (archive) { await processArchiveNodeApiLogs(logsDir); } await processMultiNodeLogs(logFilePaths, logsDir); } return logsDir; } catch (error) { debugLog('%o', error); fs.removeSync(logsDir); return null; } } function generateLogsDirPath() { const timeZoneOffset = new Date().getTimezoneOffset() * 60000; const localMoment = new Date(Date.now() - timeZoneOffset); return path.resolve( `${lightnetLogsDir}/${localMoment .toISOString() .split('.')[0] .replace(/:/g, '-')}` ); } function getLogFilePaths(mode) { return [...Constants.lightnetProcessToLogFileMapping.values()].map( (value) => { const logFilePaths = value.split(','); return mode === 'single-node' || logFilePaths.length === 1 ? logFilePaths[0] : logFilePaths[1]; } ); } async function processSingleNodeLogs(logFilePaths, logsDir) { for (const logFilePath of logFilePaths) { try { await copyContainerLogToHost( logFilePath, logsDir, ContainerLogFilesPrefix.SINGLE_NODE ); } catch (error) { /* istanbul ignore next */ debugLog('%o', error); } } } async function processMultiNodeLogs(logFilePaths, logsDir) { for (const logFilePath of logFilePaths) { try { await copyContainerLogToHost( logFilePath, logsDir, ContainerLogFilesPrefix.MULTI_NODE ); } catch (error) { /* istanbul ignore next */ debugLog('%o', error); } } } async function processArchiveNodeApiLogs(logsDir) { try { await copyContainerLogToHost( Constants.lightnetProcessToLogFileMapping.get(archiveNodeApiProcessName), logsDir, ContainerLogFilesPrefix.SINGLE_NODE ); } catch (error) { /* istanbul ignore next */ debugLog('%o', error); } } async function copyContainerLogToHost(logFilePath, logsDir, prefix) { const destinationFilePath = path.resolve( `${logsDir}/${logFilePath.replace(/\//g, '_')}` ); await executeCmd( `docker cp ${lightnetDockerContainerName}:${prefix}/${logFilePath} ${destinationFilePath}` ); } async function getBlockchainNetworkReadinessMaxAttempts(mode) { const { cpu: availableCpus } = await getAvailableDockerEngineResources(); const baseCpus = 8; const singleNodeBaseAttempts = 50; const multiNodeBaseAttempts = 210; const cpusDiff = availableCpus - baseCpus; let adjustmentFactor; if (cpusDiff <= 0) { // Increase maxAttempts for fewer than base CPUs adjustmentFactor = 1 + Math.abs(cpusDiff) / baseCpus; } else { // Decrease maxAttempts for more than base CPUs adjustmentFactor = 1 / (1 + cpusDiff / baseCpus); } const baseAttempts = mode === 'single-node' ? singleNodeBaseAttempts : multiNodeBaseAttempts; const maxAttempts = Math.round(baseAttempts * adjustmentFactor); debugLog( 'Calculated maximum blockchain network readiness check attempts: %d', maxAttempts ); return maxAttempts; } async function waitForBlockchainNetworkReadiness(mode) { let blockchainSyncAttempt = 1; let blockchainIsReady = false; const maxAttempts = await getBlockchainNetworkReadinessMaxAttempts(mode); const pollingIntervalMs = 10_000; const syncStatusGraphQlQuery = { query: '{ syncStatus }', variables: null, operationName: null, }; const debugMessage = 'Blockchain network readiness check attempt #%d, retrying in %d seconds...'; const checkEndpoint = async (url) => { try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(syncStatusGraphQlQuery), }); debugLog( 'The endpoint checking network response was Ok? %s (HTTP status: %s)', response.ok, response.status ); if (response.ok) { const responseJson = await response.json(); if (responseJson?.data?.syncStatus === 'SYNCED') { return true; } } } catch (e) { debugLog('The endpoint checking procedure failed with the error: %s', e); } return false; }; while (blockchainSyncAttempt <= maxAttempts && !blockchainIsReady) { blockchainIsReady = (await checkEndpoint(Constants.lightnetMinaDaemonGraphQlEndpoint)) || (await checkEndpoint( Constants.lightnetMinaDaemonGraphQlEndpoint.replace( '127.0.0.1', 'localhost' ) )); if (!blockchainIsReady) { debugLog(debugMessage, blockchainSyncAttempt, pollingIntervalMs / 1_000); await sleep(pollingIntervalMs); blockchainSyncAttempt++; } } if (!blockchainIsReady) { const statusColored = chalk.red.bold('is not ready'); console.log( '\n\nMaximum blockchain network readiness check attempts reached.' + `\nThe blockchain network ${statusColored}.` + '\nPlease consider cleaning up the environment by executing:\n\n' + chalk.green.bold('zk lightnet stop') + '\n' ); shell.exit(1); } } async function printExtendedDockerContainerState(containerName) { const { stdout } = await executeCmd( `docker inspect -f ` + `${quotes}Status: {{.State.Status}}; ` + `Is running: {{.State.Running}}; ` + `{{if .State.ExitCode}}Exit code: {{.State.ExitCode}}; {{end}}` + `Killed by OOM: {{.State.OOMKilled}}; ` + `{{if .State.Error}}Error: {{.State.Error}}{{end}}${quotes} ${containerName}` ); const boldTitle = chalk.reset.bold('\nDocker container state\n'); console.log(boldTitle + stdout.trim()); } function printUsefulUrls() { const lightnetConfig = fs.readJSONSync(lightnetConfigFile); const archive = lightnetConfig.archive; const border = getBorderCharacters('norc'); const boldTitle = chalk.reset.bold('\nUseful URLs'); const urls = [ [ chalk.bold('Mina Daemon GraphQL endpoint'), chalk.reset(Constants.lightnetMinaDaemonGraphQlEndpoint), ], [ chalk.bold('Accounts Manager endpoint'), chalk.reset(Constants.lightnetAccountManagerEndpoint), ], ]; if (archive) { urls.push([ chalk.bold('Archive-Node-API endpoint'), chalk.reset(Constants.lightnetArchiveNodeApiEndpoint), ]); urls.push([ chalk.bold('PostgreSQL connection string'), chalk.reset('postgresql://postgres:postgres@127.0.0.1:5432/archive'), ]); } console.log(boldTitle); console.log( table(urls, { border, }) ); } function printDockerContainerProcessesLogPaths() { const lightnetConfig = fs.readJSONSync(lightnetConfigFile); const mode = lightnetConfig.mode; const border = getBorderCharacters('norc'); const boldTitle = chalk.reset.bold( 'Logs produced by different processes are redirected into the files' + '\nlocated by the following path patterns inside the container:' ); const logs = [ [chalk.reset(`${ContainerLogFilesPrefix.SINGLE_NODE}/logs/*.log`)], ]; if (mode === 'multi-node') { logs.push([ chalk.reset(`${ContainerLogFilesPrefix.MULTI_NODE}/**/logs/*.log`), ]); } console.log(boldTitle); console.log( table(logs, { border, }) ); console.log( chalk.yellow.bold('Note:') + ' By default, important logs of the current session will be saved' + '\nto the host file system during the ' + chalk.green.bold('zk lightnet stop') + ' command execution.' + '\nTo disable this behavior, please use the ' + chalk.reset.bold('--no-save-logs') + ' option.' ); } async function printBlockchainNetworkProperties() { const border = getBorderCharacters('norc'); const boldTitle = chalk.reset.bold('\nBlockchain network properties'); const noData = [ [chalk.yellow('No data available yet. Please try again a bit later.')], ]; let data = null; try { const graphQlQuery = { query: `{ syncStatus daemonStatus { chainId consensusConfiguration { k slotDuration slotsPerEpoch } commitId uptimeSecs consensusMechanism snarkWorkFee numAccounts } }`, variables: null, operationName: null, }; debugLog( 'Fetching the blockchain network properties using GraphQL endpoint %s and query: %O', Constants.lightnetMinaDaemonGraphQlEndpoint, graphQlQuery ); const response = await fetch(Constants.lightnetMinaDaemonGraphQlEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(graphQlQuery), }); if (!response.ok) { data = noData; } else { const responseJson = await response.json(); if (!responseJson?.data) { data = noData; } else { data = [ [ chalk.bold('Sync status'), chalk.reset(responseJson.data.syncStatus), ], [ chalk.bold('Commit ID'), chalk.reset(responseJson.data.daemonStatus.commitId), ], [ chalk.bold('Chain ID'), chalk.reset(responseJson.data.daemonStatus.chainId), ], [ chalk.bold('Consensus mechanism'), chalk.reset(responseJson.data.daemonStatus.consensusMechanism), ], [ chalk.bold('Consensus configuration'), chalk.reset( `Transaction finality ("k" blocks): ${responseJson.data.daemonStatus.consensusConfiguration.k}` + `\nSlot duration (new block every ~): ${ responseJson.data.daemonStatus.consensusConfiguration .slotDuration / 1_000 } seconds` + `\nSlots per Epoch: ${responseJson.data.daemonStatus.consensusConfiguration.slotsPerEpoch}` ), ], [ chalk.bold('SNARK work fee'), chalk.reset( `${ responseJson.data.daemonStatus.snarkWorkFee / 1_000_000_000 } MINA` ), ], [ chalk.bold('Known accounts'), chalk.reset(responseJson.data.daemonStatus.numAccounts), ], [ chalk.bold('Uptime'), chalk.reset( secondsToHms(responseJson.data.daemonStatus.uptimeSecs) ), ], ]; } } } catch (error) { debugLog( 'Issue happened while printing the blockchain network properties:\n%o', error ); data = noData; } console.log(boldTitle); console.log( table(data, { border, }) ); } function printZkAppSnippet() { const boldTitle = chalk.reset.bold('zkApp snippet using o1js API'); console.log(boldTitle); console.log( chalk.dim( `import { Lightnet, Mina, ... } from 'o1js'; // Network configuration const network = Mina.Network({ mina: '${Constants.lightnetMinaDaemonGraphQlEndpoint}', archive: '${Constants.lightnetArchiveNodeApiEndpoint}', lightnetAccountManager: '${Constants.lightnetAccountManagerEndpoint}', }); Mina.setActiveInstance(network); // Fee payer setup const feePayerPrivateKey = (await Lightnet.acquireKeyPair()).privateKey const feePayerAccount = feePayerPrivateKey.toPublicKey(); ... // Release previously acquired key pair const keyPairReleaseMessage = await Lightnet.releaseKeyPair({ publicKey: feePayerAccount.toBase58(), }); if (keyPairReleaseMessage) console.log(keyPairReleaseMessage);` ) ); } function secondsToHms(seconds) { seconds = Number(seconds); const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor((seconds % 3600) % 60); let hDisplay = ''; if (h > 0) { hDisplay = h + (h == 1 ? ' hour, ' : ' hours, '); } let mDisplay = ''; if (m > 0) { mDisplay = m + (m == 1 ? ' minute, ' : ' minutes, '); } let sDisplay = ''; if (s > 0) { sDisplay = s + (s == 1 ? ' second' : ' seconds'); } const result = hDisplay + mDisplay + sDisplay; return result.endsWith(', ') ? result.slice(0, -2) : result; } async function isEnoughDockerEngineResourcesAvailable(mode, archive) { const { memoryGB: availableMemGB } = await getAvailableDockerEngineResources(); let baseRequiredMemGB = 3.5; if (mode === 'single-node' && archive) { baseRequiredMemGB += 1.0; } else if (mode === 'multi-node') { baseRequiredMemGB = 16.0; } if (availableMemGB < baseRequiredMemGB) { return { error: true, message: `Insufficient Docker Engine resources available. The lightweight Mina blockchain network requires at least ${baseRequiredMemGB} GB of RAM to start.`, }; } else { return { error: false }; } } function getRequiredDockerContainerPorts(mode, archive) { let ports = commonServicesPorts; if (mode === 'single-node') { ports = ports.concat(singleNodePorts); } else { ports = ports.concat(multiNodePorts); } if (archive) { ports = ports.concat(archivePorts); } return ports; } function getDockerContainerStartupCmdPorts(mode, archive) { return getRequiredDockerContainerPorts(mode, archive) .map((port) => `-p 127.0.0.1:${port}:${port} `) .join(''); } function printCmdDebugLog(command, stdOut, stdErr) { let logMessage = chalk.bold(`${command}`); if (stdOut) { logMessage += chalk.reset(`\nStdOut:\n%o`); } if (stdErr) { logMessage += chalk.reset(`\nStdErr:\n%o`); } debugLog(logMessage, stdOut, stdErr); } async function shellExec(command, options = {}) { return new Promise((resolve) => { shell.exec(command, options, (code, stdout, stderr) => { resolve({ code, stdout, stderr }); }); }); } async function executeCmd(command, silent = true) { const { code, stdout, stderr } = await shellExec(command, { silent }); printCmdDebugLog(command, stdout, stderr); return { code, stdout, stderr }; } function buildDebugLogger() { return (formatter, ...args) => { if (process.env.DEBUG) { const namespaces = process.env.DEBUG.split(','); if ( namespaces.includes('*') || namespaces.includes('zk:*') || namespaces.includes('zk:lightnet') ) { // We want to outline the debug output, so we print new line first. console.log(''); } } debug(formatter, ...args); }; } function getSystemQuotes() { let quotes = "'"; let escapeQuotes = ''; if (process.platform === 'win32') { quotes = '"'; const { code } = shell.exec('Get-ChildItem', { silent: true }); // Is it PowerShell? if (code == 0) { escapeQuotes = '\\`'; } else { escapeQuotes = '\\"'; } } return { quotes, escapeQuotes }; }