UNPKG

hardhat

Version:

Hardhat is an extensible developer tool that helps smart contract developers increase productivity by reliably bringing together the tools they want.

281 lines 12.1 kB
import crypto from "node:crypto"; import path from "node:path"; import { HardhatError } from "@nomicfoundation/hardhat-errors"; import { formatTable } from "@nomicfoundation/hardhat-utils/format"; import { ensureDir, exists, getAllFilesMatching, isDirectory, readJsonFile, remove, writeJsonFile, } from "@nomicfoundation/hardhat-utils/fs"; import { findDuplicates } from "@nomicfoundation/hardhat-utils/lang"; import chalk from "chalk"; import debug from "debug"; import { parseFullyQualifiedName } from "../../../utils/contract-names.js"; const gasStatsLog = debug("hardhat:core:gas-analytics:gas-analytics-manager:gas-stats"); export class GasAnalyticsManagerImplementation { gasMeasurements = []; #gasStatsPath; #reportEnabled = true; constructor(gasStatsRootPath) { this.#gasStatsPath = path.join(gasStatsRootPath, "gas-stats"); } addGasMeasurement(gasMeasurement) { this.gasMeasurements.push(gasMeasurement); } async clearGasMeasurements(id) { const gasMeasurementsPath = await this.#getGasMeasurementsPath(id); await remove(gasMeasurementsPath); this.gasMeasurements = []; gasStatsLog("Cleared gas measurements from disk and memory"); } async saveGasMeasurements(id) { const gasMeasurementsPath = await this.#getGasMeasurementsPath(id); const filePath = path.join(gasMeasurementsPath, `${crypto.randomUUID()}.json`); await writeJsonFile(filePath, this.gasMeasurements); gasStatsLog("Saved gas measurements", id, filePath); } async reportGasStats(...ids) { if (!this.#reportEnabled) { return; } await this._loadGasMeasurements(...ids); const gasStatsByContract = this._calculateGasStats(); const report = this._generateGasStatsReport(gasStatsByContract); console.log(report); console.log(); gasStatsLog("Printed markdown report"); } async writeGasStatsJson(outputPath, ...ids) { if (!this.#reportEnabled) { return; } await this._loadGasMeasurements(...ids); const gasStatsByContract = this._calculateGasStats(); const resolvedPath = path.resolve(outputPath); if ((await exists(resolvedPath)) && (await isDirectory(resolvedPath))) { throw new HardhatError(HardhatError.ERRORS.CORE.BUILTIN_TASKS.INVALID_FILE_PATH, { path: outputPath }); } await ensureDir(path.dirname(resolvedPath)); const json = this._generateGasStatsJson(gasStatsByContract); await writeJsonFile(resolvedPath, json); gasStatsLog("Written gas stats JSON to", resolvedPath); } enableReport() { this.#reportEnabled = true; } disableReport() { this.#reportEnabled = false; } async #getGasMeasurementsPath(id) { const gasMeasurementsPath = path.join(this.#gasStatsPath, id); await ensureDir(gasMeasurementsPath); return gasMeasurementsPath; } /** * @private exposed for testing purposes only */ async _loadGasMeasurements(...ids) { this.gasMeasurements = []; for (const id of ids) { const gasMeasurementsPath = await this.#getGasMeasurementsPath(id); const filePaths = await getAllFilesMatching(gasMeasurementsPath); for (const filePath of filePaths) { const entries = await readJsonFile(filePath); for (const entry of entries) { this.gasMeasurements.push(entry); } gasStatsLog("Loaded gas measurements", id, filePath); } } } /** * @private exposed for testing purposes only */ _calculateGasStats() { const gasStatsByContract = new Map(); const measurementsByContract = this._aggregateGasMeasurements(); for (const [contractFqn, measurements] of measurementsByContract) { const contractGasStats = { functions: new Map(), }; if (measurements.deployments.length > 0) { contractGasStats.deployment = { min: Math.min(...measurements.deployments), max: Math.max(...measurements.deployments), avg: Math.round(avg(measurements.deployments)), median: Math.round(median(measurements.deployments)), count: measurements.deployments.length, }; } const overloadedFnNames = findDuplicates([...measurements.functions.keys()].map(getFunctionName)); for (const [functionSig, gasValues] of measurements.functions) { const functionName = getFunctionName(functionSig); const isOverloaded = overloadedFnNames.has(functionName); const stats = { min: Math.min(...gasValues), max: Math.max(...gasValues), avg: Math.round(avg(gasValues)), median: Math.round(median(gasValues)), count: gasValues.length, }; contractGasStats.functions.set(isOverloaded ? functionSig : functionName, stats); } gasStatsByContract.set(contractFqn, contractGasStats); } return gasStatsByContract; } /** * @private exposed for testing purposes only */ _aggregateGasMeasurements() { const measurementsByContract = new Map(); for (const currentMeasurement of this.gasMeasurements) { let contractMeasurements = measurementsByContract.get(currentMeasurement.contractFqn); if (contractMeasurements === undefined) { contractMeasurements = { deployments: [], functions: new Map(), }; measurementsByContract.set(currentMeasurement.contractFqn, contractMeasurements); } if (currentMeasurement.type === "deployment") { contractMeasurements.deployments.push(currentMeasurement.gas); } else { let measurements = contractMeasurements.functions.get(currentMeasurement.functionSig); if (measurements === undefined) { measurements = []; contractMeasurements.functions.set(currentMeasurement.functionSig, measurements); } measurements.push(currentMeasurement.gas); } } return measurementsByContract; } /** * @private exposed for testing purposes only */ _generateGasStatsReport(gasStatsByContract) { const rows = []; if (gasStatsByContract.size > 0) { rows.push({ type: "title", text: chalk.bold("Gas Usage Statistics") }); } // Sort contracts alphabetically for consistent output const sortedContracts = [...gasStatsByContract.entries()].sort(([a], [b]) => a.localeCompare(b)); for (const [contractFqn, contractGasStats] of sortedContracts) { rows.push({ type: "section-header", text: chalk.cyan.bold(getUserFqn(contractFqn)), }); if (contractGasStats.functions.size > 0) { rows.push({ type: "header", cells: [ "Function name", "Min", "Average", "Median", "Max", "#calls", ].map((s) => chalk.yellow(s)), }); } // Sort functions by removing trailing ) and comparing alphabetically. // This ensures that overloaded functions with fewer params come first // (e.g., foo(uint256) comes before foo(uint256,uint256)). In other // scenarios, removing the trailing ) has no effect on the order. const sortedFunctions = [...contractGasStats.functions.entries()].sort(([a], [b]) => a.split(")")[0].localeCompare(b.split(")")[0])); for (const [functionDisplayName, gasStats] of sortedFunctions) { rows.push({ type: "row", cells: [ functionDisplayName, `${gasStats.min}`, `${gasStats.avg}`, `${gasStats.median}`, `${gasStats.max}`, `${gasStats.count}`, ], }); } if (contractGasStats.deployment !== undefined) { rows.push({ type: "header", cells: [ "Deployment", "Min", "Average", "Median", "Max", "#deployments", ].map((s) => chalk.yellow(s)), }); rows.push({ type: "row", cells: [ "", `${contractGasStats.deployment.min}`, `${contractGasStats.deployment.avg}`, `${contractGasStats.deployment.median}`, `${contractGasStats.deployment.max}`, `${contractGasStats.deployment.count}`, ], }); } } return formatTable(rows); } /** * @private exposed for testing purposes only */ _generateGasStatsJson(gasStatsByContract) { const sortedContracts = [...gasStatsByContract.entries()] .map(([internalFqn, stats]) => ({ userFqn: getUserFqn(internalFqn), stats, })) .sort((a, b) => a.userFqn.localeCompare(b.userFqn)); const contracts = {}; for (const { userFqn, stats } of sortedContracts) { const { sourceName, contractName } = parseFullyQualifiedName(userFqn); const deployment = stats.deployment !== undefined ? { ...stats.deployment } : null; let functions = null; if (stats.functions.size > 0) { functions = {}; // Sort functions by removing trailing ) and comparing alphabetically. // This ensures that overloaded functions with fewer params come first // (e.g., foo(uint256) comes before foo(uint256,uint256)). In other // scenarios, removing the trailing ) has no effect on the order. const sortedFunctions = [...stats.functions.entries()].sort(([a], [b]) => a.split(")")[0].localeCompare(b.split(")")[0])); functions = Object.fromEntries(sortedFunctions); } contracts[userFqn] = { sourceName, contractName, deployment, functions }; } return { contracts }; } } export function avg(values) { return values.reduce((a, c) => a + c, 0) / values.length; } export function median(values) { const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } export function getUserFqn(inputFqn) { if (inputFqn.startsWith("project/")) { return inputFqn.slice("project/".length); } if (inputFqn.startsWith("npm/")) { const withoutPrefix = inputFqn.slice("npm/".length); // Match "<pkg>@<version>/<rest>", where <pkg> may be scoped (@scope/pkg) const match = withoutPrefix.match(/^(@?[^@/]+(?:\/[^@/]+)*)@[^/]+\/(.*)$/); if (match !== null) { return `${match[1]}/${match[2]}`; } return withoutPrefix; } return inputFqn; } export function getFunctionName(signature) { return signature.split("(")[0]; } //# sourceMappingURL=gas-analytics-manager.js.map